Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
import { getLang, t, localePath, type Lang } from "~/i18n";
import { getStarCount, formatStars } from "~/lib/github";
import { findDoc } from "~/content/docs/_meta";
import { findDoc, getProduct } from "~/content/docs/_meta";
import SearchModal from "./SearchModal.astro";
import TwMark from "./TwMark.astro";

Expand Down Expand Up @@ -44,7 +44,8 @@ function targetPath(target: Lang): string {
if (/^\/404(\.html)?\/?$/.test(cleanPath)) return "/";
if (currentDocMeta && !currentDocMeta.locales.includes(target)) {
// Untranslated doc: fall back to that product's docs home.
return docMatch?.[1] ? `/docs/${docMatch[1]}` : "/docs";
const product = getProduct((docMatch?.[1] as "lite" | "core" | undefined) ?? "thinkwatch");
return product.home ?? product.base;
}
return cleanPath;
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/pages/ThinkWatchPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import QuickStart from "~/components/sections/QuickStart.astro";
import LogExplorerMock from "~/components/mocks/LogExplorerMock.tsx";
import NeonMotion from "~/components/NeonMotion.astro";
import { getLang, localePath } from "~/i18n";
import { productHomeHref } from "~/content/docs/_meta";
import { getLatestRelease } from "~/lib/github";
import { enterpriseLd } from "~/lib/structured-data";
import { thinkwatchCopy } from "~/i18n/pages/thinkwatch";
Expand Down Expand Up @@ -53,7 +54,7 @@ const delay = (i: number) => `animation-delay: ${i * 90}ms;`;
<p class="nx-lede nx-rise" style="--d:2">{c.hero.sub}</p>
<div class="nx-actions nx-rise" style="--d:3">
<a href="#quickstart" class="nx-pill">{c.hero.ctaPrimary}</a>
<a href={localePath(lang, "/docs")} class="nx-ghost">{c.hero.ctaSecondary}</a>
<a href={productHomeHref(lang, "thinkwatch")} class="nx-ghost">{c.hero.ctaSecondary}</a>
</div>
<ul class="hero-pills nx-rise" style="--d:4">
{pills.map((p) => (
Expand Down
17 changes: 12 additions & 5 deletions src/content/docs/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ export type Product = {
id: ProductId;
/** Brand name, not translated */
name: string;
/** Docs home path without locale prefix, e.g. "/docs/lite" */
/** Docs path without locale prefix that the product's doc slugs sit under, e.g. "/docs/lite" */
base: string;
/** The product's docs home, when it is not `base` itself */
home?: string;
/** Where the markdown sources live ("Edit on GitHub") */
editUrl: string;
/** One-liner shown on the documentation home */
Expand All @@ -59,8 +61,12 @@ export const products: Product[] = [
{
id: "thinkwatch",
name: "ThinkWatch Enterprise",
// Its guides keep their /docs/<slug> addresses; /docs itself is the
// documentation home for all three products, so its own home is an
// overview page beside the guides.
base: "/docs",
editUrl: "https://github.com/ThinkWatchProject/ThinkWatch/tree/main/docs",
home: "/docs/overview",
editUrl: "https://github.com/ThinkWatchProject/thinkwatch.github.io/tree/main/src/content/docs",
tagline: {
en: "The gateway for teams and enterprises. Deploy, configure, and operate it in production.",
"zh-CN": "面向团队与企业的网关。在生产环境中部署、配置与运维。",
Expand Down Expand Up @@ -289,16 +295,17 @@ export function getProduct(id: ProductId): Product {

/** Docs home URL of a product, localized. */
export function productHomeHref(lang: Lang, id: ProductId): string {
return localePath(lang, getProduct(id).base);
const p = getProduct(id);
return localePath(lang, p.home ?? p.base);
}

/**
* URL of a doc. Untranslated docs fall back to the English URL, which is
* how the sidebar has always handled missing translations.
*/
export function docHref(lang: Lang, id: ProductId, doc: DocMeta): string {
const base = getProduct(id).base;
const path = doc.slug ? `${base}/${doc.slug}` : base;
const p = getProduct(id);
const path = doc.slug ? `${p.base}/${doc.slug}` : (p.home ?? p.base);
return doc.locales.includes(lang) ? localePath(lang, path) : path;
}

Expand Down
25 changes: 25 additions & 0 deletions src/content/docs/en/overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# ThinkWatch Enterprise

ThinkWatch Enterprise is a self-hosted AI API and MCP gateway for organizations. Every model request and every MCP tool call passes through one gateway, where it is authenticated against the organization's identity provider, checked against limits and budgets, inspected by security guards, priced, and written to the audit log. It plays the role for AI access that a bastion host plays for server access.

> It is [deployed](/docs/deployment-guide) with Docker Compose or the Kubernetes Helm chart. Clients only need to reach the gateway on port 3000; the console on port 3001 serves the management UI and admin API and belongs behind a VPN or firewall.

## Highlights

- **MCP tool calls run as the real user.** Each user connects their own GitHub, Notion, Linear, Slack or Atlassian account, so the upstream's own audit log shows who acted. Each tool can be granted per role and per API key.
- **Security guards on every request.** Personal information is replaced with placeholders before a request goes upstream and restored in the answer. Tool calls in model responses are checked against rules for dangerous commands, and hidden Unicode characters and prompt-injection phrases in requests are logged or refused.
- **Identity from the organization's directory.** Sign-in works through any OIDC provider, with optional TOTP. Five built-in roles and custom roles decide who may use which models, tools and admin pages.
- **One key for AI and MCP.** `tw-` virtual keys can be scoped to the AI gateway, the MCP gateway or both. Keys are stored only as hashes and rotate with a grace period.
- **Rate limits and budgets.** Sliding windows from one minute to one week limit requests or tokens, and daily, weekly or monthly budgets cap spending. Both attach to users, API keys or roles.
- **Cost accounting that finance can use.** Spend is reported by model, user, provider and cost center, with CSV chargeback reports. A month-end forecast comes with it.
- **Audit trail in ClickHouse.** Every model request and tool call is recorded with user, parameters, response, latency and errors. Events can be forwarded to a SIEM over Syslog, Kafka or signed webhooks.
- **One endpoint for every client.** OpenAI Chat Completions, OpenAI Responses, Anthropic Messages and Gemini requests are served on one port and converted to whatever the upstream speaks. Routing spreads traffic by weight, latency or health, and a circuit breaker takes failing upstreams out of rotation.

## Reading order

1. [Architecture](/docs/architecture): the dual-port model, the request lifecycle and the data flow.
2. [Deployment Guide](/docs/deployment-guide): Docker Compose, the Helm chart, TLS and production hardening.
3. [Configuration](/docs/configuration): environment variables and system settings.
4. [Security](/docs/security): authentication, encryption, RBAC and the hardening checklist.

ThinkWatch Enterprise is source-available under the Business Source License 1.1: free for non-production use, and free in production up to monthly thresholds. See [License](/license).
25 changes: 25 additions & 0 deletions src/content/docs/zh-CN/overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# ThinkWatch 企业版

ThinkWatch 企业版是面向组织自托管的 AI API 与 MCP 网关。组织内的每一次模型请求和 MCP 工具调用都经过同一个网关:以组织的身份系统认证,按限流与预算检查,经安全防护审查,核算费用并写入审计日志。它在 AI 访问中的作用,相当于堡垒机在服务器访问中的作用。

> 可用 Docker Compose 或 Kubernetes Helm Chart [部署](/zh-CN/docs/deployment-guide)。客户端只需访问网关端口 3000;控制台端口 3001 提供管理界面与管理 API,应置于 VPN 或防火墙之后。

## 要点

- **MCP 工具调用以真实用户身份执行。** 每位用户连接自己的 GitHub、Notion、Linear、Slack、Atlassian 等账号,上游自身的审计日志因此能记录到具体操作人。每个工具可以按角色和按 API Key 授权。
- **每个请求都经过安全防护。** 个人信息在请求发往上游前替换为占位符,并在回答中还原。模型返回的工具调用按危险命令规则检查,请求中的隐藏 Unicode 字符和提示词注入语句会被记录或拒绝。
- **身份来自组织目录。** 登录可对接任意 OIDC 提供商,并可启用 TOTP 两步验证。五个内置角色与自定义角色决定每个人可用的模型、工具和管理页面。
- **AI 与 MCP 共用一把密钥。** `tw-` 虚拟密钥可限定用于 AI 网关、MCP 网关或两者。密钥只以哈希形式保存,轮换时保留宽限期。
- **限流与预算。** 一分钟到一周的滑动窗口限制请求数或 token 数,按日、周、月的预算控制总用量。两者均可设置在用户、API Key 或角色上。
- **可用于财务核算的费用统计。** 费用按模型、用户、上游和成本中心汇总,可导出 CSV 分摊报表。月末费用另有预测。
- **审计记录存入 ClickHouse。** 每一次模型请求和工具调用都记录用户、参数、响应、延迟与错误。审计事件可通过 Syslog、Kafka 或签名 Webhook 转发至 SIEM。
- **所有客户端共用一个入口。** OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 与 Gemini 请求在同一端口提供,并转换为上游所用的格式。路由按权重、延迟或健康状况分配流量,熔断器将持续出错的上游移出轮转。

## 阅读顺序

1. [架构设计](/zh-CN/docs/architecture):双端口模型、请求生命周期与数据流。
2. [部署指南](/zh-CN/docs/deployment-guide):Docker Compose、Helm Chart、TLS 与生产环境加固。
3. [配置说明](/zh-CN/docs/configuration):环境变量与系统设置。
4. [安全模型](/zh-CN/docs/security):认证、加密、RBAC 与加固清单。

ThinkWatch 企业版在 Business Source License 1.1 下源码开放:非生产环境免费,生产环境在月度阈值以内免费,详见[许可证](/zh-CN/license)。
4 changes: 2 additions & 2 deletions src/layouts/DocsLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const productLabel = zh ? "产品" : "Product";
const searchLabel = zh ? `搜索 ${productName(p, lang)} 文档` : `Search ${productName(p, lang)} docs`;
const pageLabel = currentDoc?.label[lang] ?? title.split(" · ")[0];
// Title stored in the search index: on a product home "Overview" alone would be ambiguous.
const searchTitle = current === "" ? (product === "thinkwatch" ? title.split(" · ")[0] : productName(p, lang)) : pageLabel;
const searchTitle = hub ? title.split(" · ")[0] : current === "" ? productName(p, lang) : pageLabel;

// Only show H2/H3 in the TOC; hide if there's only the (auto-generated) doc title.
// rehype-autolink-headings appends a literal "#" text node to each heading,
Expand Down Expand Up @@ -139,7 +139,7 @@ const updatedLabel = zh ? `最后更新于 ${updated}` : `Last updated ${updated
)}

{hub ? (
<main id="main" class="container-page" data-pagefind-body data-pagefind-meta={`title:${searchTitle}`} data-pagefind-filter={`product:${p.id}`}>
<main id="main" class="container-page" data-pagefind-body data-pagefind-meta={`title:${searchTitle}`}>
<slot />
<slot name="after" />
</main>
Expand Down
6 changes: 2 additions & 4 deletions src/pages/docs/_DocArticle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,13 @@ const site = Astro.site ?? new URL("https://thinkwat.ch");
const abs = (path: string) => new URL(path.endsWith("/") ? path : `${path}/`, site).toString();
const pageUrl = new URL(Astro.url.pathname, site).toString();

// Home › Documentation › product › doc. ThinkWatch Enterprise's docs home is
// the documentation home itself (/docs), so its guides skip the product step
// rather than list the same page twice.
// Home › Documentation › product › doc.
const crumbs = [
{ name: zh ? "首页" : "Home", item: abs(localePath(lang, "/")) },
{ name: zh ? "文档" : "Documentation", item: abs(localePath(lang, "/docs")) },
{ name: productName(p, lang), item: slug ? abs(productHomeHref(lang, product)) : pageUrl },
...(slug ? [{ name: docLabel, item: pageUrl }] : []),
].filter((crumb, i, all) => i === 0 || crumb.item !== all[i - 1].item);
];

const jsonLd: Record<string, unknown>[] = [
{
Expand Down
13 changes: 7 additions & 6 deletions src/pages/docs/_lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ export type DocsEntry =

const collectionFor = { thinkwatch: "docs", lite: "docs_lite", core: "docs_core" } as const;

/** Markdown file rendered as the docs home of Lite and Core. */
/** Markdown file rendered as each product's docs home. */
export const HOME_ENTRY = "overview";

/**
* Path segments under /docs owned by product routes. ThinkWatch guides are
* served by docs/[...slug].astro, so these must never be generated there.
* Path segments under /docs owned by other routes (the Lite and Core docs, and
* ThinkWatch Enterprise's overview). ThinkWatch guides are served by
* docs/[...slug].astro, so these must never be generated there.
*/
export const RESERVED_SLUGS = ["lite", "core"];
export const RESERVED_SLUGS = ["lite", "core", HOME_ENTRY];

export async function getProductEntries(product: ProductId, lang: Lang) {
const all: DocsEntry[] = [
Expand Down Expand Up @@ -48,8 +49,8 @@ export async function getArticlePaths(product: ProductId, lang: Lang) {
.map(({ entry, slug }) => ({ params: { slug }, props: { entry, slug } }));
}

/** The markdown entry rendered as a product's docs home (Lite and Core only). */
export async function getHomeEntry(product: Exclude<ProductId, "thinkwatch">, lang: Lang): Promise<DocsEntry> {
/** The markdown entry rendered as a product's docs home. */
export async function getHomeEntry(product: ProductId, lang: Lang): Promise<DocsEntry> {
const entries = await getProductEntries(product, lang);
const home = entries.find(({ slug }) => slug === HOME_ENTRY);
if (!home) throw new Error(`[docs] missing ${product} ${lang}/${HOME_ENTRY}.md`);
Expand Down
10 changes: 10 additions & 0 deletions src/pages/docs/overview.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
// ThinkWatch Enterprise's docs home. Its guides are /docs/<slug>; /docs is the
// documentation home for all three products.
import DocArticle from "~/pages/docs/_DocArticle.astro";
import { getHomeEntry } from "~/pages/docs/_lib";

const entry = await getHomeEntry("thinkwatch", "en");
---

<DocArticle lang="en" product="thinkwatch" slug="" entry={entry} />
10 changes: 10 additions & 0 deletions src/pages/zh-CN/docs/overview.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
// ThinkWatch Enterprise's docs home. Its guides are /docs/<slug>; /docs is the
// documentation home for all three products.
import DocArticle from "~/pages/docs/_DocArticle.astro";
import { getHomeEntry } from "~/pages/docs/_lib";

const entry = await getHomeEntry("thinkwatch", "zh-CN");
---

<DocArticle lang="zh-CN" product="thinkwatch" slug="" entry={entry} />
Loading