From 3018b2159dec48240d449c6bd6a8d41b6751a5d7 Mon Sep 17 00:00:00 2001 From: xiteng Date: Thu, 13 Aug 2026 22:59:29 +0800 Subject: [PATCH] refactor(chat): split Turnfold into standalone repository --- .gitmodules | 3 + chat/.dockerignore | 7 - chat/Dockerfile | 25 - chat/README.md | 20 + chat/app | 1 + chat/build.ts | 25 - chat/bun.lock | 147 - chat/compose.yml | 15 +- chat/entrypoint.sh | 7 - chat/history.test.mjs | 333 --- chat/lib/browser-provider-fetch.ts | 23 - chat/lib/browser-provider-settings.ts | 24 - chat/lib/conversation-client.ts | 199 -- chat/lib/conversation-hash.test.mjs | 18 - chat/lib/conversation-hash.ts | 13 - chat/lib/conversation-title.test.mjs | 21 - chat/lib/conversation-title.ts | 37 - chat/lib/conversation-types.ts | 108 - chat/lib/conversations.ts | 705 ----- chat/lib/fullscreen-editor.test.mjs | 21 - chat/lib/fullscreen-editor.ts | 8 - chat/lib/generation-settings.test.mjs | 64 - chat/lib/generation-settings.ts | 55 - chat/lib/import-title-template.test.mjs | 31 - chat/lib/import-title-template.ts | 32 - chat/lib/incremental-markdown-cache.test.mjs | 73 - chat/lib/incremental-markdown-cache.ts | 79 - chat/lib/key-vault.ts | 53 - chat/lib/llama-cpp-discovery.test.mjs | 44 - chat/lib/local-credentials.ts | 66 - chat/lib/math-markdown.test.mjs | 39 - chat/lib/math-markdown.ts | 41 - chat/lib/message-graph.test.mjs | 35 - chat/lib/message-graph.ts | 62 - chat/lib/message-object.ts | 29 - chat/lib/model-display.test.mjs | 14 - chat/lib/model-display.ts | 5 - chat/lib/offline-history.ts | 763 ----- chat/lib/profile-types.ts | 5 - chat/lib/provider-connectivity.ts | 188 -- chat/lib/provider-model.ts | 66 - chat/lib/provider-types.ts | 45 - chat/lib/repository-push-batches.test.mjs | 20 - chat/lib/repository-push-batches.ts | 21 - chat/lib/response-metadata.test.mjs | 26 - chat/lib/response-metadata.ts | 26 - chat/lib/server-provider-fetch.ts | 16 - chat/lib/session-transfer.test.mjs | 81 - chat/lib/session-transfer.ts | 485 ---- chat/lib/streaming-markdown.test.mjs | 48 - chat/lib/streaming-markdown.ts | 77 - chat/package.json | 30 - chat/public/favicon.svg | 1 - chat/public/icons/apple-touch-icon.png | Bin 14662 -> 0 bytes chat/public/icons/icon-192.png | Bin 14532 -> 0 bytes chat/public/icons/icon-512.png | Bin 81767 -> 0 bytes chat/public/icons/icon-maskable-512.png | Bin 81767 -> 0 bytes chat/public/manifest.webmanifest | 20 - chat/public/sw.js | 58 - chat/src/client.ts | 2608 ------------------ chat/src/index.html | 24 - chat/src/server.ts | 576 ---- chat/src/styles.css | 426 --- chat/tsconfig.json | 18 - 64 files changed, 35 insertions(+), 8075 deletions(-) create mode 100644 .gitmodules delete mode 100644 chat/.dockerignore delete mode 100644 chat/Dockerfile create mode 100644 chat/README.md create mode 160000 chat/app delete mode 100644 chat/build.ts delete mode 100644 chat/bun.lock delete mode 100755 chat/entrypoint.sh delete mode 100644 chat/history.test.mjs delete mode 100644 chat/lib/browser-provider-fetch.ts delete mode 100644 chat/lib/browser-provider-settings.ts delete mode 100644 chat/lib/conversation-client.ts delete mode 100644 chat/lib/conversation-hash.test.mjs delete mode 100644 chat/lib/conversation-hash.ts delete mode 100644 chat/lib/conversation-title.test.mjs delete mode 100644 chat/lib/conversation-title.ts delete mode 100644 chat/lib/conversation-types.ts delete mode 100644 chat/lib/conversations.ts delete mode 100644 chat/lib/fullscreen-editor.test.mjs delete mode 100644 chat/lib/fullscreen-editor.ts delete mode 100644 chat/lib/generation-settings.test.mjs delete mode 100644 chat/lib/generation-settings.ts delete mode 100644 chat/lib/import-title-template.test.mjs delete mode 100644 chat/lib/import-title-template.ts delete mode 100644 chat/lib/incremental-markdown-cache.test.mjs delete mode 100644 chat/lib/incremental-markdown-cache.ts delete mode 100644 chat/lib/key-vault.ts delete mode 100644 chat/lib/llama-cpp-discovery.test.mjs delete mode 100644 chat/lib/local-credentials.ts delete mode 100644 chat/lib/math-markdown.test.mjs delete mode 100644 chat/lib/math-markdown.ts delete mode 100644 chat/lib/message-graph.test.mjs delete mode 100644 chat/lib/message-graph.ts delete mode 100644 chat/lib/message-object.ts delete mode 100644 chat/lib/model-display.test.mjs delete mode 100644 chat/lib/model-display.ts delete mode 100644 chat/lib/offline-history.ts delete mode 100644 chat/lib/profile-types.ts delete mode 100644 chat/lib/provider-connectivity.ts delete mode 100644 chat/lib/provider-model.ts delete mode 100644 chat/lib/provider-types.ts delete mode 100644 chat/lib/repository-push-batches.test.mjs delete mode 100644 chat/lib/repository-push-batches.ts delete mode 100644 chat/lib/response-metadata.test.mjs delete mode 100644 chat/lib/response-metadata.ts delete mode 100644 chat/lib/server-provider-fetch.ts delete mode 100644 chat/lib/session-transfer.test.mjs delete mode 100644 chat/lib/session-transfer.ts delete mode 100644 chat/lib/streaming-markdown.test.mjs delete mode 100644 chat/lib/streaming-markdown.ts delete mode 100644 chat/package.json delete mode 100644 chat/public/favicon.svg delete mode 100644 chat/public/icons/apple-touch-icon.png delete mode 100644 chat/public/icons/icon-192.png delete mode 100644 chat/public/icons/icon-512.png delete mode 100644 chat/public/icons/icon-maskable-512.png delete mode 100644 chat/public/manifest.webmanifest delete mode 100644 chat/public/sw.js delete mode 100644 chat/src/client.ts delete mode 100644 chat/src/index.html delete mode 100644 chat/src/server.ts delete mode 100644 chat/src/styles.css delete mode 100644 chat/tsconfig.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f3d00b2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "chat/app"] + path = chat/app + url = https://github.com/liooil/turnfold.git diff --git a/chat/.dockerignore b/chat/.dockerignore deleted file mode 100644 index cbfaa50..0000000 --- a/chat/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -.next -package-lock.json -npm-debug.log -data -run diff --git a/chat/Dockerfile b/chat/Dockerfile deleted file mode 100644 index a8cd0f2..0000000 --- a/chat/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM oven/bun:1.3.5-alpine AS dependencies -WORKDIR /app -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile --production - -FROM oven/bun:1.3.5-alpine AS builder -WORKDIR /app -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile -COPY . . -RUN bun run build - -FROM oven/bun:1.3.5-alpine AS runner -WORKDIR /app -ENV NODE_ENV=production -ENV PORT=3000 -ENV STATIC_ROOT=/app/dist -RUN apk add --no-cache su-exec && addgroup -S app && adduser -S app -G app -COPY --from=dependencies --chown=app:app /app/node_modules ./node_modules -COPY --from=builder --chown=app:app /app/dist ./dist -COPY --from=builder --chown=app:app /app/src/server.ts ./src/server.ts -COPY --from=builder --chown=app:app /app/lib ./lib -COPY --chmod=755 entrypoint.sh /entrypoint.sh -EXPOSE 3000 -ENTRYPOINT ["/entrypoint.sh"] diff --git a/chat/README.md b/chat/README.md new file mode 100644 index 0000000..b2d700e --- /dev/null +++ b/chat/README.md @@ -0,0 +1,20 @@ +# Turnfold deployment + +The application source is maintained in the public +[`liooil/turnfold`](https://github.com/liooil/turnfold) repository and mounted here +as the `app` Git submodule. This directory only keeps xiteng.site-specific +deployment configuration and the local provider bridge. + +After cloning the homelab repository, initialize the application source with: + +```sh +git submodule update --init chat/app +``` + +To deploy the pinned Turnfold revision: + +```sh +docker compose -f chat/compose.yml up -d --build +``` + +Runtime conversation data remains in the untracked `chat/data/` directory. diff --git a/chat/app b/chat/app new file mode 160000 index 0000000..fe261e8 --- /dev/null +++ b/chat/app @@ -0,0 +1 @@ +Subproject commit fe261e8f07cf6a32af0ad0e5ab32ee83d51214b0 diff --git a/chat/build.ts b/chat/build.ts deleted file mode 100644 index e0501d9..0000000 --- a/chat/build.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {cpSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; - -rmSync("dist", {recursive: true, force: true}); -mkdirSync("dist/assets", {recursive: true}); -const result = await Bun.build({ - entrypoints: ["src/client.ts"], - outdir: "dist/assets", - target: "browser", - minify: true, - sourcemap: "none", - naming: "client.js" -}); -if (!result.success) { - for (const log of result.logs) console.error(log); - process.exit(1); -} -const version = Bun.hash(`${readFileSync("src/client.ts", "utf8")}\0${readFileSync("src/styles.css", "utf8")}`).toString(36); -writeFileSync("dist/index.html", readFileSync("src/index.html", "utf8").replaceAll("__ASSET_VERSION__", version)); -cpSync("src/styles.css", "dist/styles.css"); -cpSync("public", "dist", {recursive: true}); -mkdirSync("dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg/dynamic", {recursive: true}); -cpSync("node_modules/mathjax/tex-svg-nofont.js", "dist/assets/mathjax/4.1.3/tex-svg-nofont.js"); -cpSync("node_modules/@mathjax/mathjax-newcm-font/svg.js", "dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg.js"); -cpSync("node_modules/@mathjax/mathjax-newcm-font/svg/dynamic", "dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg/dynamic", {recursive: true}); -cpSync("node_modules/mathjax/input/tex/extensions", "dist/assets/mathjax/4.1.3/input/tex/extensions", {recursive: true}); diff --git a/chat/bun.lock b/chat/bun.lock deleted file mode 100644 index f5282df..0000000 --- a/chat/bun.lock +++ /dev/null @@ -1,147 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "xiteng-chat", - "dependencies": { - "@ai-sdk/anthropic": "4.0.36", - "@ai-sdk/google": "4.0.39", - "@ai-sdk/openai": "4.0.36", - "@ai-sdk/openai-compatible": "3.0.28", - "ai": "7.0.58", - "dompurify": "3.2.6", - "fflate": "0.8.2", - "marked": "15.0.12", - "node-fetch": "3.3.2", - "proxy-agent": "8.0.2", - }, - "devDependencies": { - "@types/bun": "1.3.5", - "mathjax": "4.1.3", - "typescript": "5.9.3", - }, - }, - }, - "packages": { - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wg5jfray0X4+qkrr73GZ7U7K2JNGx+S+rNY9LorVlXhkhTqnvtNLYWRA1WxSxlCDCw3yjRCJdL9kyc+b7naAog=="], - - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="], - - "@ai-sdk/google": ["@ai-sdk/google@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+mRx7UBZn9PkJ4J6YXowaRZKMZYa290cknVqqOw/roZaDg186IUOLn9JHNQkvgaj91/MLW1AZNESy1ZD2yXDCg=="], - - "@ai-sdk/openai": ["@ai-sdk/openai@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wHJNArBdjJPXb8GXcA+FslbRt+7qIE1KoHSAVi+CeBFidinVrTVBZKFMJz3mNZMj8YILBMl/VIvTD/oGFjX6/g=="], - - "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@3.0.28", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vF/852mCFiASq2fAPE1mA0E0w78NH68/X2Gukuh4TeJrMIp+CJfoR5QLuY2+pTx7UiLmuYWDpCQhWaupuM7+Xg=="], - - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], - - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.25", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA=="], - - "@mathjax/mathjax-newcm-font": ["@mathjax/mathjax-newcm-font@4.1.3", "", {}, "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], - - "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], - - "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], - - "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], - - "ai": ["ai@7.0.58", "", { "dependencies": { "@ai-sdk/gateway": "4.0.46", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfgO90CQQ0yYuoxJAUOeQ6tviyYw1BUIDygSZ1q3Ce6kSc93tYmB5eltKY/NxC0YOouAax7JvDqVnYxvIAr04Q=="], - - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], - - "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], - - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], - - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "degenerator": ["degenerator@7.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w=="], - - "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], - - "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], - - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], - - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], - - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - - "get-uri": ["get-uri@8.0.1", "", { "dependencies": { "basic-ftp": "^5.3.1", "data-uri-to-buffer": "8.0.0", "debug": "^4.3.4" } }, "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww=="], - - "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], - - "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], - - "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], - - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - - "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - - "mathjax": ["mathjax@4.1.3", "", { "dependencies": { "@mathjax/mathjax-newcm-font": "^4.1.3" } }, "sha512-BN/8Pkgn7G1pIDYJqd9md+JHsE/jydSYbyOZnSdSA0WziuVO8mRxdYiWFumkVVly/8U+hm9DpIIoWuvySverzw=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], - - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - - "pac-proxy-agent": ["pac-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "get-uri": "8.0.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "pac-resolver": "9.0.1", "quickjs-wasi": "^2.2.0", "socks-proxy-agent": "10.1.0" } }, "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA=="], - - "pac-resolver": ["pac-resolver@9.0.1", "", { "dependencies": { "degenerator": "7.0.1", "netmask": "^2.0.2" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw=="], - - "proxy-agent": ["proxy-agent@8.0.2", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "lru-cache": "^7.14.1", "pac-proxy-agent": "9.1.0", "proxy-from-env": "^2.0.0", "socks-proxy-agent": "10.1.0" } }, "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A=="], - - "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], - - "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - - "quickjs-wasi": ["quickjs-wasi@2.2.0", "", {}, "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw=="], - - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], - - "socks-proxy-agent": ["socks-proxy-agent@10.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@8.0.0", "", {}, "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ=="], - } -} diff --git a/chat/compose.yml b/chat/compose.yml index 373f781..baf700b 100644 --- a/chat/compose.yml +++ b/chat/compose.yml @@ -85,15 +85,22 @@ services: chat: build: - context: . - image: xiteng-chat:local + context: ./app + args: + BASE_PATH: /chat + HOME_URL: / + image: turnfold:local container_name: xiteng-chat restart: unless-stopped environment: + AUTH_MODE: forward-auth + AUTH_ISSUER: https://auth.xiteng.site + BASE_PATH: /chat KEY_VAULT_URL: http://ai-gateway:8093 KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac CHAT_DATABASE_PATH: /data/chat.db PORTAL_URL: http://xiteng-site:8080 + ACCOUNT_URL: https://xiteng.site/account PUBLIC_PROVIDER_CATALOG_FILE: /app/providers.json volumes: - ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro @@ -158,8 +165,8 @@ services: - "xiteng.site.cache.chat.edge-ttl=604800" - "xiteng.site.cache.chat.stale-while-revalidate=86400" - "xiteng.site.component.chat.enabled=true" - - "xiteng.site.component.chat.name=Xiteng Chat" - - "xiteng.site.component.chat.description=本地优先的 AI 对话界面;无需登录即可使用浏览器 Provider,登录后启用个人同步与 Key Vault。" + - "xiteng.site.component.chat.name=Turnfold" + - "xiteng.site.component.chat.description=本地优先、支持分支式历史的 AI 对话仓库;无需登录即可使用浏览器 Provider,登录后启用个人同步与 Key Vault。" - "xiteng.site.component.chat.section=services" - "xiteng.site.component.chat.category=AI" - "xiteng.site.component.chat.url=https://xiteng.site/chat/" diff --git a/chat/entrypoint.sh b/chat/entrypoint.sh deleted file mode 100755 index 46b4c51..0000000 --- a/chat/entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -set -eu - -install -m 0400 -o app -g app "$KEY_VAULT_TOKEN_FILE" /tmp/key_vault_service_token -export KEY_VAULT_TOKEN_FILE=/tmp/key_vault_service_token -install -d -m 0700 -o app -g app /data -exec su-exec app bun src/server.ts diff --git a/chat/history.test.mjs b/chat/history.test.mjs deleted file mode 100644 index 1a3f8eb..0000000 --- a/chat/history.test.mjs +++ /dev/null @@ -1,333 +0,0 @@ -import assert from "node:assert/strict"; -import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; -import http from "node:http"; -import {tmpdir} from "node:os"; -import path from "node:path"; -import {spawn} from "node:child_process"; -import {Database} from "bun:sqlite"; -import {createHash} from "node:crypto"; -import {createMessageObject} from "./lib/message-object.ts"; - -const root = process.cwd(); -const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "xiteng-chat-history-test-")); -const databasePath = path.join(temporaryDirectory, "chat.db"); -const tokenPath = path.join(temporaryDirectory, "service-token"); -writeFileSync(tokenPath, "history-test-token"); - -const legacyDatabase = new Database(databasePath, {create: true}); -legacyDatabase.run(` - CREATE TABLE chat_conversation ( - id TEXT PRIMARY KEY, owner_issuer TEXT NOT NULL, owner_sub TEXT NOT NULL, title TEXT NOT NULL, - provider_id TEXT NOT NULL, model TEXT NOT NULL, settings_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, updated_at TEXT NOT NULL - ); - CREATE TABLE chat_message ( - conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, - id TEXT NOT NULL, ordinal INTEGER NOT NULL, role TEXT NOT NULL, parts_json TEXT NOT NULL, - created_at TEXT NOT NULL, PRIMARY KEY (conversation_id, id), UNIQUE (conversation_id, ordinal) - ); -`); -const legacyTimestamp = new Date().toISOString(); -legacyDatabase.query("INSERT INTO chat_conversation VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") - .run("legacy-conversation", "https://auth.xiteng.site", "legacy-sub", "legacy/chat", "openai", "gpt-legacy", "{}", legacyTimestamp, legacyTimestamp); -legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)") - .run("legacy-conversation", "legacy-user", 0, "user", JSON.stringify([{type: "text", text: "legacy question"}]), legacyTimestamp); -legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)") - .run("legacy-conversation", "legacy-assistant", 1, "assistant", JSON.stringify([{type: "text", text: "legacy answer"}]), legacyTimestamp); -legacyDatabase.close(); - -function availablePort() { - return new Promise((resolve, reject) => { - const server = http.createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const port = server.address().port; - server.close(() => resolve(port)); - }); - }); -} - -async function waitForServer(url, child) { - const deadline = Date.now() + 30000; - while (Date.now() < deadline) { - if (child.exitCode !== null) throw new Error(`Bun server exited with code ${child.exitCode}`); - try { - if ((await fetch(url)).ok) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error("Timed out waiting for history test server"); -} - -async function startServer() { - const port = await availablePort(); - const child = spawn(process.execPath, [path.join(root, "src/server.ts")], { - cwd: root, - env: { - ...process.env, - PORT: String(port), - STATIC_ROOT: path.join(root, "dist"), - CHAT_DATABASE_PATH: databasePath, - KEY_VAULT_URL: "http://127.0.0.1:1", - KEY_VAULT_TOKEN_FILE: tokenPath - }, - stdio: "ignore" - }); - await waitForServer(`http://127.0.0.1:${port}/chat/api/health`, child); - return {child, origin: `http://127.0.0.1:${port}`}; -} - -function stopServer(child) { - if (child.exitCode !== null) return Promise.resolve(); - return new Promise((resolve) => { - child.once("exit", resolve); - child.kill("SIGTERM"); - }); -} - -async function api(origin, identity, pathname, init = {}) { - const response = await fetch(`${origin}/chat${pathname}`, { - ...init, - headers: { - "X-Authentik-Username": identity.username, - "X-Authentik-Uid": identity.sub, - ...(init.body ? {"Content-Type": "application/json"} : {}), - ...(init.headers || {}) - } - }); - const payload = response.status === 204 ? null : await response.json(); - return {response, payload}; -} - -const owner = {username: "history-owner", sub: "owner-sub"}; -const other = {username: "history-other", sub: "other-sub"}; -const legacyOwner = {username: "history-legacy", sub: "legacy-sub"}; -const distributedOwner = {username: "distributed-owner", sub: "distributed-sub"}; -const localRepositoryOwner = {username: "local-repository-owner", sub: "local-repository-sub"}; -const untitledOwner = {username: "untitled-owner", sub: "untitled-sub"}; -let server; -try { - server = await startServer(); - const publicConfigResponse = await fetch(`${server.origin}/chat/api/public-config`); - assert.equal(publicConfigResponse.status, 200); - const publicConfig = await publicConfigResponse.json(); - assert.ok(publicConfig.providers.length > 0); - assert.ok(publicConfig.providers.every((provider) => provider.connection.type === "frontend" && provider.credentials.length === 0)); - const anonymousPrivateConfig = await fetch(`${server.origin}/chat/api/config`); - assert.equal(anonymousPrivateConfig.status, 401); - const migratedLegacy = await api(server.origin, legacyOwner, "/api/conversations/legacy-conversation"); - assert.equal(migratedLegacy.response.status, 200); - assert.equal(migratedLegacy.payload.conversation.name, "legacy/chat"); - assert.equal(migratedLegacy.payload.conversation.messages.length, 2); - assert.ok(migratedLegacy.payload.conversation.messages.every((message) => message.id.startsWith("sha256:"))); - assert.equal(migratedLegacy.payload.conversation.messages[1].parentMessageId, migratedLegacy.payload.conversation.messages[0].id); - const initialSettings = {reasoning: "low", showReasoningSummary: false, temperature: null, maxOutputTokens: null}; - const createdResult = await api(server.origin, owner, "/api/conversations", { - method: "POST", - body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat"}) - }); - assert.equal(createdResult.response.status, 201); - assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings); - assert.equal(createdResult.payload.conversation.name, "work/chat"); - assert.equal(createdResult.payload.conversation.headMessageId, null); - const untitledResult = await api(server.origin, untitledOwner, "/api/conversations", { - method: "POST", - body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings}) - }); - assert.equal(untitledResult.response.status, 201); - assert.equal(untitledResult.payload.conversation.name, ""); - const untitledUser = await api(server.origin, untitledOwner, `/api/conversations/${untitledResult.payload.conversation.id}/messages`, { - method: "POST", - body: JSON.stringify({ - id: "untitled-user", - expectedHeadId: null, - parentMessageId: null, - role: "user", - parts: [{type: "text", text: "这段文字不能直接成为标题"}], - origin: {type: "user"}, - completion: {status: "complete"} - }) - }); - assert.equal(untitledUser.response.status, 201); - assert.equal(untitledUser.payload.conversation.name, ""); - const conversationId = createdResult.payload.conversation.id; - const userResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { - method: "POST", - body: JSON.stringify({ - id: "user-1", - expectedHeadId: null, - parentMessageId: null, - role: "user", - parts: [{type: "text", text: "persistent question"}], - origin: {type: "user", clientId: "history-test"}, - completion: {status: "complete"} - }) - }); - assert.equal(userResult.response.status, 201); - assert.equal(userResult.payload.conversation.headMessageId, "user-1"); - const responseMetadata = {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20}; - const assistantResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { - method: "POST", - body: JSON.stringify({ - id: "assistant-1", - expectedHeadId: "user-1", - parentMessageId: "user-1", - role: "assistant", - parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}], - origin: {type: "model", providerId: "openai", model: "gpt-test", attemptId: "attempt-1"}, - completion: {status: "complete"}, - metadata: {custom: {response: responseMetadata}} - }) - }); - assert.equal(assistantResult.response.status, 201); - assert.equal(assistantResult.payload.conversation.messageCount, 2); - const conflict = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { - method: "POST", - body: JSON.stringify({id: "conflict", expectedHeadId: "user-1", parentMessageId: "user-1", role: "assistant", parts: [], origin: {type: "legacy"}, completion: {status: "complete"}}) - }); - assert.equal(conflict.response.status, 409); - const updatedSettings = {reasoning: "high", showReasoningSummary: true, temperature: 0.7, maxOutputTokens: 4096}; - const settingsResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, { - method: "PATCH", - body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: updatedSettings, name: "work/chat/main"}) - }); - assert.equal(settingsResult.response.status, 200); - assert.equal(settingsResult.payload.conversation.name, "work/chat/main"); - - const branchResult = await api(server.origin, owner, "/api/conversations", { - method: "POST", - body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat/branch", headMessageId: "user-1"}) - }); - assert.equal(branchResult.response.status, 201); - assert.equal(branchResult.payload.conversation.messageCount, 1); - assert.equal(branchResult.payload.conversation.messages[0].id, "user-1"); - - const namespace = createHash("sha256").update(`https://auth.xiteng.site\0${distributedOwner.sub}`).digest("hex").slice(0, 32); - const objectTimestamp = new Date().toISOString(); - const distributedMessage = await createMessageObject({ - parentMessageId: null, - role: "user", - parts: [{type: "text", text: "local-first message"}], - origin: {type: "user", clientId: "test-replica"}, - completion: {status: "complete"}, - createdAt: objectTimestamp, - completedAt: objectTimestamp - }, namespace); - const distributedRef = { - conversationId: "distributed-conversation", - expectedHeadMessageId: null, - expectedHeadVersion: 0, - expectedMetadataVersion: 0, - headMessageId: distributedMessage.id, - name: "", - providerId: "openai", - model: "gpt-test", - generationSettings: initialSettings, - createdAt: objectTimestamp, - updatedAt: objectTimestamp - }; - const pushed = await api(server.origin, distributedOwner, "/api/sync/push", { - method: "POST", - body: JSON.stringify({objects: [distributedMessage], refs: [distributedRef]}) - }); - assert.equal(pushed.response.status, 200); - assert.equal(pushed.payload.refs[0].status, "ok"); - assert.equal(pushed.payload.refs[0].ref.headMessageId, distributedMessage.id); - const fetched = await api(server.origin, distributedOwner, "/api/sync/fetch", { - method: "POST", - body: JSON.stringify({haveObjectIds: []}) - }); - assert.equal(fetched.response.status, 200); - assert.equal(fetched.payload.objects[0].id, distributedMessage.id); - assert.equal(fetched.payload.refs[0].id, "distributed-conversation"); - assert.equal(fetched.payload.refs[0].name, ""); - const fetchedWithHave = await api(server.origin, distributedOwner, "/api/sync/fetch", { - method: "POST", - body: JSON.stringify({haveObjectIds: [distributedMessage.id]}) - }); - assert.deepEqual(fetchedWithHave.payload.objects, []); - const alternateMessage = await createMessageObject({ - parentMessageId: null, - role: "user", - parts: [{type: "text", text: "alternate root"}], - origin: {type: "user", clientId: "test-replica", sourceMessageId: distributedMessage.id}, - completion: {status: "complete"}, - createdAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString(), - completedAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString() - }, namespace); - const movedRef = await api(server.origin, distributedOwner, "/api/sync/push", { - method: "POST", - body: JSON.stringify({ - objects: [alternateMessage], - refs: [{ - ...distributedRef, - expectedHeadMessageId: distributedMessage.id, - expectedHeadVersion: pushed.payload.refs[0].ref.headVersion, - expectedMetadataVersion: pushed.payload.refs[0].ref.metadataVersion, - headMessageId: alternateMessage.id - }] - }) - }); - assert.equal(movedRef.payload.refs[0].status, "ok"); - const graphFetch = await api(server.origin, distributedOwner, "/api/sync/fetch", { - method: "POST", - body: JSON.stringify({haveObjectIds: []}) - }); - assert.deepEqual(new Set(graphFetch.payload.objects.map((message) => message.id)), new Set([distributedMessage.id, alternateMessage.id])); - const localRepositoryId = "local:12345678-1234-4234-8234-123456789abc"; - const localRepositoryMessage = await createMessageObject({ - parentMessageId: null, - role: "user", - parts: [{type: "text", text: "created before login"}], - origin: {type: "user", clientId: "12345678-1234-4234-8234-123456789abc"}, - completion: {status: "complete"}, - createdAt: objectTimestamp, - completedAt: objectTimestamp - }, localRepositoryId); - const localRepositoryPush = await api(server.origin, localRepositoryOwner, "/api/sync/push", { - method: "POST", - body: JSON.stringify({ - repositoryId: localRepositoryId, - objects: [localRepositoryMessage], - refs: [{...distributedRef, conversationId: "pre-login-conversation", headMessageId: localRepositoryMessage.id}] - }) - }); - assert.equal(localRepositoryPush.response.status, 200); - assert.equal(localRepositoryPush.payload.refs[0].status, "ok"); - const rejectedHash = await api(server.origin, distributedOwner, "/api/sync/push", { - method: "POST", - body: JSON.stringify({objects: [{...distributedMessage, id: `sha256:${"0".repeat(64)}`}], refs: []}) - }); - assert.equal(rejectedHash.response.status, 400); - const rejectedLease = await api(server.origin, distributedOwner, "/api/sync/push", { - method: "POST", - body: JSON.stringify({objects: [], refs: [{...distributedRef, expectedHeadVersion: 0, headMessageId: null}]}) - }); - assert.equal(rejectedLease.payload.refs[0].status, "conflict"); - - await stopServer(server.child); - server = await startServer(); - const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`); - assert.equal(restored.response.status, 200); - assert.equal(restored.payload.conversation.messages.length, 2); - assert.ok(restored.payload.conversation.messages.every((message) => message.id.startsWith("sha256:"))); - assert.equal(restored.payload.conversation.messages[1].parentMessageId, restored.payload.conversation.messages[0].id); - assert.deepEqual(restored.payload.conversation.messages[1].metadata.custom.response, responseMetadata); - assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings); - - const ownerList = await api(server.origin, owner, "/api/conversations"); - assert.equal(ownerList.payload.conversations.length, 2); - - const otherList = await api(server.origin, other, "/api/conversations"); - assert.deepEqual(otherList.payload.conversations, []); - assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`)).response.status, 404); - assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 404); - - assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 204); - assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`)).response.status, 404); - assert.equal((await api(server.origin, owner, `/api/conversations/${branchResult.payload.conversation.id}`)).payload.conversation.messages[0].parts[0].text, "persistent question"); - console.log("Chat history API tests passed"); -} finally { - if (server) await stopServer(server.child); - rmSync(temporaryDirectory, {recursive: true, force: true}); -} diff --git a/chat/lib/browser-provider-fetch.ts b/chat/lib/browser-provider-fetch.ts deleted file mode 100644 index 56f525b..0000000 --- a/chat/lib/browser-provider-fetch.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {ProviderDefinition, ProviderSecret} from "./provider-types"; - -export function createBrowserProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) { - const proxy = provider.connection.proxy; - if (!proxy) return fetch; - if (proxy.type !== "relay") throw new Error(`Unsupported frontend proxy: ${proxy.type}`); - return async (input: RequestInfo | URL, init: RequestInit = {}) => { - const headers = new Headers(init.headers); - const relayHeaders: Record = {"Content-Type": "application/json"}; - if (secret.proxy?.token) relayHeaders.Authorization = `Bearer ${secret.proxy.token}`; - return fetch(proxy.url, { - method: "POST", - headers: relayHeaders, - body: JSON.stringify({ - url: String(input), - method: init.method || "GET", - headers: Object.fromEntries(headers.entries()), - body: typeof init.body === "string" ? init.body : null - }), - signal: init.signal - }); - }; -} diff --git a/chat/lib/browser-provider-settings.ts b/chat/lib/browser-provider-settings.ts deleted file mode 100644 index 423b479..0000000 --- a/chat/lib/browser-provider-settings.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {ProviderDefinition, ProviderSecret} from "./provider-types"; - -function inferredDiscoveryUrl(provider: ProviderDefinition, baseUrl: string) { - if (provider.discovery.type === "anthropic-models-list") return `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`; - if (provider.discovery.type === "google-models-list") return `${baseUrl}/models?pageSize=200`; - return `${baseUrl}/models`; -} - -export function applyBrowserProviderSettings(provider: ProviderDefinition, secret: ProviderSecret) { - const configuredBaseUrl = secret.provider?.baseUrl?.trim(); - if (!configuredBaseUrl) return provider; - const url = new URL(configuredBaseUrl); - if (!["http:", "https:"].includes(url.protocol)) throw new Error("Provider Base URL 必须使用 http 或 https"); - const baseUrl = configuredBaseUrl.replace(/\/+$/, ""); - const originalBaseUrl = provider.connection.baseUrl.replace(/\/+$/, ""); - const discoveryUrl = provider.discovery.url.startsWith(originalBaseUrl) - ? inferredDiscoveryUrl(provider, baseUrl) - : provider.discovery.url; - return { - ...provider, - connection: {...provider.connection, baseUrl}, - discovery: {...provider.discovery, url: discoveryUrl} - }; -} diff --git a/chat/lib/conversation-client.ts b/chat/lib/conversation-client.ts deleted file mode 100644 index 36c4c7a..0000000 --- a/chat/lib/conversation-client.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { - Conversation, - ConversationRefState, - ConversationSummary, - MessageCompletion, - MessageOrigin, - RepositoryFetch, - StoredChatMessage -} from "./conversation-types"; -import type {GenerationSettings} from "./generation-settings"; -import { - applyRepositoryFetch, - applyRepositoryPushResults, - cacheConversation, - cacheConversationSummaries, - commitLocalMessage, - createLocalConversation, - deleteLocalConversation, - listCachedObjectIds, - listPendingConversationChanges, - loadCachedConversation, - loadCachedConversationSummaries, - moveLocalConversationHead, - queueConversationChange, - queueLocalRefUpdate, - removePendingConversationChange, - repositoryPushPayload -} from "./offline-history"; -import {repositoryPushBatches} from "./repository-push-batches"; - -const chatBasePath = "/chat"; -const chatApi = (pathname: string) => `${chatBasePath}${pathname}`; - -export type MessageCommitInput = { - id: string; - expectedHeadId: string | null; - parentMessageId: string | null; - role: StoredChatMessage["role"]; - parts: StoredChatMessage["parts"]; - origin: MessageOrigin; - completion: MessageCompletion; - createdAt: string; - completedAt: string; - metadata?: StoredChatMessage["metadata"]; - providerId?: string; - model?: string; -}; - -class ConversationHttpError extends Error { - constructor(message: string, readonly status: number) { - super(message); - } -} - -async function conversationRequest(path: string, init?: RequestInit) { - const response = await fetch(path, { - cache: "no-store", - ...init, - headers: { - "Accept": "application/json", - ...(init?.body ? {"Content-Type": "application/json"} : {}), - ...(init?.headers || {}) - } - }); - if (response.status === 204) return undefined as T; - const payload = await response.json(); - if (!response.ok) throw new ConversationHttpError(payload.error || `HTTP ${response.status}`, response.status); - return payload as T; -} - -export function listConversationHistory() { - return loadCachedConversationSummaries(); -} - -export async function createConversationHistory( - providerId: string, - model: string, - generationSettings: GenerationSettings, - name: string, - headMessageId: string | null = null, - messages: StoredChatMessage[] = [] -) { - const timestamp = new Date().toISOString(); - const conversation: Conversation = { - id: crypto.randomUUID(), - name, - headMessageId, - upstreamHeadMessageId: null, - providerId, - model, - generationSettings, - headVersion: 0, - metadataVersion: 0, - messageCount: messages.length, - createdAt: timestamp, - updatedAt: timestamp, - messages - }; - return createLocalConversation(conversation); -} - -export async function getConversationHistory(id: string) { - const cached = await loadCachedConversation(id); - if (!cached) throw new Error("Local conversation is unavailable; fetch may still be in progress"); - return cached; -} - -export async function updateConversationHistory( - id: string, - providerId: string, - model: string, - generationSettings: GenerationSettings, - name?: string -) { - const cached = await getConversationHistory(id); - const updated: Conversation = { - ...cached, - providerId, - model, - generationSettings, - ...(name === undefined ? {} : {name}), - updatedAt: new Date().toISOString() - }; - return queueLocalRefUpdate(updated); -} - -export async function commitConversationMessage(conversationId: string, input: MessageCommitInput) { - const cached = await getConversationHistory(conversationId); - if (cached.headMessageId !== input.expectedHeadId || input.parentMessageId !== input.expectedHeadId) throw new Error("Local conversation head changed"); - const message: StoredChatMessage = { - id: input.id, - parentMessageId: input.parentMessageId, - role: input.role, - parts: input.parts, - origin: input.origin, - completion: input.completion, - createdAt: input.createdAt, - completedAt: input.completedAt, - ...(input.metadata ? {metadata: input.metadata} : {}) - }; - return commitLocalMessage(conversationId, message); -} - -export function moveConversationHead(conversationId: string, headMessageId: string | null) { - return moveLocalConversationHead(conversationId, headMessageId); -} - -// Compatibility for queued operations produced by an older client. -export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) { - const body = JSON.stringify({providerId, model, messages}); - const payload = await conversationRequest<{conversation: Conversation}>(chatApi(`/api/conversations/${encodeURIComponent(id)}`), {method: "PUT", body}); - await cacheConversation(payload.conversation); - return payload.conversation; -} - -export async function deleteConversationHistory(id: string) { - await deleteLocalConversation(id); -} - -export async function flushPendingConversationChanges() { - const pending = await listPendingConversationChanges(); - for (const change of pending) { - const requestPath = change.requestPath || `/api/conversations/${encodeURIComponent(change.conversationId)}`; - try { - await conversationRequest(chatApi(requestPath), {method: change.method, body: change.body}); - await removePendingConversationChange(change.cacheKey); - } catch (error) { - if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) { - await removePendingConversationChange(change.cacheKey); - continue; - } - throw error; - } - } -} - -export async function synchronizeConversationRepository() { - await flushPendingConversationChanges(); - const push = await repositoryPushPayload(); - let conflicts = 0; - if (push.refs.length || push.objects.length) { - for (const batch of repositoryPushBatches(push)) { - const pushed = await conversationRequest<{ - refs: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>; - }>(chatApi("/api/sync/push"), {method: "POST", body: JSON.stringify(batch)}); - await applyRepositoryPushResults(pushed.refs); - conflicts += pushed.refs.filter((result) => result.status === "conflict").length; - } - } - const haveObjectIds = await listCachedObjectIds(); - const fetched = await conversationRequest(chatApi("/api/sync/fetch"), { - method: "POST", - body: JSON.stringify({haveObjectIds}) - }); - await applyRepositoryFetch(fetched); - return {summaries: await loadCachedConversationSummaries(), fetchedAt: fetched.fetchedAt, conflicts}; -} - -export const synchronizeOfflineConversationHistory = synchronizeConversationRepository; diff --git a/chat/lib/conversation-hash.test.mjs b/chat/lib/conversation-hash.test.mjs deleted file mode 100644 index e76d733..0000000 --- a/chat/lib/conversation-hash.test.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {conversationHash, conversationIdFromHash} from "./conversation-hash.ts"; - -describe("conversation hash routing", () => { - test("round-trips a conversation id", () => { - const id = "f60dbe18-92ca-4a7d-9d5d-242d0ed4d042"; - expect(conversationIdFromHash(conversationHash(id))).toBe(id); - }); - - test("preserves URL-sensitive ids", () => { - expect(conversationIdFromHash(conversationHash("local/id + draft"))).toBe("local/id + draft"); - }); - - test("ignores unrelated or oversized hashes", () => { - expect(conversationIdFromHash("#services")).toBe(""); - expect(conversationIdFromHash(`#conversation=${"x".repeat(121)}`)).toBe(""); - }); -}); diff --git a/chat/lib/conversation-hash.ts b/chat/lib/conversation-hash.ts deleted file mode 100644 index d00995c..0000000 --- a/chat/lib/conversation-hash.ts +++ /dev/null @@ -1,13 +0,0 @@ -const conversationHashKey = "conversation"; - -export function conversationIdFromHash(hash: string) { - const input = hash.startsWith("#") ? hash.slice(1) : hash; - const id = new URLSearchParams(input).get(conversationHashKey)?.trim() || ""; - return id && id.length <= 120 ? id : ""; -} - -export function conversationHash(id: string) { - const parameters = new URLSearchParams(); - parameters.set(conversationHashKey, id); - return `#${parameters.toString()}`; -} diff --git a/chat/lib/conversation-title.test.mjs b/chat/lib/conversation-title.test.mjs deleted file mode 100644 index e50ecaf..0000000 --- a/chat/lib/conversation-title.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {conversationTitlePrompt, normalizeGeneratedConversationTitle} from "./conversation-title.ts"; - -describe("AI conversation titles", () => { - test("builds a bounded prompt from the first messages", () => { - const prompt = conversationTitlePrompt([ - {role: "user", parts: [{type: "text", text: "如何优化流式 Markdown?"}]}, - {role: "assistant", parts: [{type: "text", text: "可以按稳定块增量渲染。"}]} - ]); - expect(prompt).toContain("用户:如何优化流式 Markdown?"); - expect(prompt).toContain("助手:可以按稳定块增量渲染。"); - }); - - test("removes common model formatting and unsafe path separators", () => { - expect(normalizeGeneratedConversationTitle("## 标题:\“流式 Markdown / 渲染优化\”\n说明")).toBe("流式 Markdown / 渲染优化"); - }); - - test("allows an empty result to remain untitled", () => { - expect(normalizeGeneratedConversationTitle("no title\n")).toBe(""); - }); -}); diff --git a/chat/lib/conversation-title.ts b/chat/lib/conversation-title.ts deleted file mode 100644 index 5c961d3..0000000 --- a/chat/lib/conversation-title.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {StoredChatMessage} from "./conversation-types"; - -export const untitledConversationLabel = "未命名会话"; - -function messageText(message: StoredChatMessage) { - return message.parts - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => String(part.text)) - .join("\n") - .trim(); -} - -export function conversationTitlePrompt(messages: StoredChatMessage[]) { - const transcript = messages - .filter((message) => message.role === "user" || message.role === "assistant") - .slice(0, 4) - .map((message) => `${message.role === "user" ? "用户" : "助手"}:${messageText(message).slice(0, 2000)}`) - .filter((line) => !line.endsWith(":")) - .join("\n\n"); - return `请为下面的对话生成一个简洁、具体的中文标题。只输出标题,不要解释,不要加引号、Markdown 或“标题:”前缀。标题不超过 30 个字符,不要使用斜杠。\n\n${transcript}`; -} - -export function normalizeGeneratedConversationTitle(value: string) { - let title = value - .replace(/[\s\S]*?<\/think>/gi, "") - .trim() - .split(/\r?\n/)[0] - .replace(/^#{1,6}\s*/, "") - .replace(/^(?:标题|title)\s*[::]\s*/i, "") - .replace(/^[`'“”‘’\"]+|[`'“”‘’\"]+$/g, "") - .replace(/[\u0000-\u001f]/g, " ") - .replace(/\s+/g, " ") - .replaceAll("/", "/") - .trim(); - title = [...title].slice(0, 60).join("").trim(); - return title; -} diff --git a/chat/lib/conversation-types.ts b/chat/lib/conversation-types.ts deleted file mode 100644 index 9029b9f..0000000 --- a/chat/lib/conversation-types.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type {GenerationSettings} from "./generation-settings"; - -export type ResponseMetadata = { - providerId: string; - model: string; - durationMs: number; - outputTokens: number | null; - tokensPerSecond: number | null; -}; - -export type MessageOrigin = - | {type: "user"; clientId?: string; sourceMessageId?: string} - | {type: "manual"; clientId?: string; sourceMessageId?: string} - | {type: "model"; providerId: string; model: string; attemptId: string} - | {type: "system"; source: string} - | {type: "legacy"}; - -export type MessageCompletion = { - status: "complete" | "partial"; - reason?: "stop" | "user-cancelled" | "connection-lost" | "provider-error" | "timeout"; -}; - -export type StoredChatMessage = { - id: string; - parentMessageId: string | null; - role: "system" | "user" | "assistant"; - parts: Array & {type: string}>; - origin: MessageOrigin; - completion: MessageCompletion; - createdAt: string; - completedAt: string; - metadata?: {custom?: {response?: ResponseMetadata}}; -}; - -export type ConversationSummary = { - id: string; - name: string; - headMessageId: string | null; - providerId: string; - model: string; - messageCount: number; - createdAt: string; - updatedAt: string; - upstreamHeadMessageId?: string | null; - headVersion?: number; - metadataVersion?: number; -}; - -export type Conversation = ConversationSummary & { - generationSettings: GenerationSettings; - messages: StoredChatMessage[]; -}; - -export type WorkingItemKind = "user-draft" | "assistant-stream"; -export type WorkingItemStatus = "editing" | "streaming" | "interrupted" | "failed"; - -export type WorkingItem = { - id: string; - conversationId: string; - kind: WorkingItemKind; - observedHeadId: string | null; - editSourceMessageId?: string; - messageRole?: "user" | "assistant"; - requestAssistantReply?: boolean; - incompleteTargetAction?: "interrupt" | "append"; - parts: StoredChatMessage["parts"]; - status: WorkingItemStatus; - attemptId?: string; - providerId?: string; - model?: string; - failureReason?: MessageCompletion["reason"]; - metadata?: StoredChatMessage["metadata"]; - createdAt: string; - updatedAt: string; -}; - -export type ConversationRefState = { - id: string; - name: string; - headMessageId: string | null; - providerId: string; - model: string; - generationSettings: GenerationSettings; - headVersion: number; - metadataVersion: number; - createdAt: string; - updatedAt: string; -}; - -export type RepositoryFetch = { - refs: ConversationRefState[]; - objects: StoredChatMessage[]; - fetchedAt: string; -}; - -export type RepositoryRefUpdate = { - conversationId: string; - expectedHeadMessageId: string | null; - expectedHeadVersion: number; - expectedMetadataVersion: number; - headMessageId: string | null; - name: string; - providerId: string; - model: string; - generationSettings: GenerationSettings; - createdAt: string; - updatedAt: string; -}; diff --git a/chat/lib/conversations.ts b/chat/lib/conversations.ts deleted file mode 100644 index 505d45a..0000000 --- a/chat/lib/conversations.ts +++ /dev/null @@ -1,705 +0,0 @@ -import {createHash, randomUUID} from "node:crypto"; -import {mkdirSync} from "node:fs"; -import path from "node:path"; -import {Database} from "bun:sqlite"; -import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings"; -import type {ChatIdentity} from "./key-vault"; -import type { - Conversation, - ConversationRefState, - ConversationSummary, - MessageCompletion, - MessageOrigin, - ResponseMetadata, - RepositoryFetch, - RepositoryRefUpdate, - StoredChatMessage -} from "./conversation-types"; -import {canonicalMessage} from "./message-object"; - -const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db"; -let database: Database | undefined; - -type ConversationRow = { - id: string; - name: string; - head_message_id: string | null; - provider_id: string; - model: string; - settings_json: string; - head_version: number; - metadata_version: number; - message_count: number; - created_at: string; - updated_at: string; -}; - -type MessageRow = { - id: string; - parent_message_id: string | null; - role: StoredChatMessage["role"]; - parts_json: string; - origin_json: string; - completion_json: string; - metadata_json: string; - depth: number; - created_at: string; - completed_at: string; -}; - -function now() { - return new Date().toISOString(); -} - -function columns(opened: Database, table: string) { - return opened.query(`PRAGMA table_info(${table})`).all() as Array<{name: string}>; -} - -function migrateLegacyMessages(opened: Database) { - const conversations = opened.query(` - SELECT id, owner_issuer, owner_sub FROM chat_conversation - WHERE head_message_id IS NULL - AND EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id) - `).all() as Array<{id: string; owner_issuer: string; owner_sub: string}>; - if (!conversations.length) return; - opened.run("BEGIN IMMEDIATE"); - try { - const existingNode = opened.query("SELECT id FROM chat_message_node WHERE id = ?"); - const insertNode = opened.query(` - INSERT INTO chat_message_node ( - id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, - completion_json, metadata_json, depth, created_at, completed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const updateHead = opened.query("UPDATE chat_conversation SET head_message_id = ? WHERE id = ?"); - for (const conversation of conversations) { - const messages = opened.query(` - SELECT id, role, parts_json, created_at FROM chat_message - WHERE conversation_id = ? ORDER BY ordinal - `).all(conversation.id) as Array<{id: string; role: StoredChatMessage["role"]; parts_json: string; created_at: string}>; - let parentId: string | null = null; - messages.forEach((legacy, depth) => { - let id = legacy.id; - if (existingNode.get(id)) id = randomUUID(); - const parsed = JSON.parse(legacy.parts_json) as StoredChatMessage["parts"]; - const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object"); - const parts = parsed.filter((part) => part.type !== "data-response-metadata"); - const metadata = metadataPart ? {custom: {response: metadataPart.data as ResponseMetadata}} : {}; - insertNode.run( - id, - conversation.owner_issuer, - conversation.owner_sub, - parentId, - legacy.role, - JSON.stringify(parts), - JSON.stringify({type: "legacy"}), - JSON.stringify({status: "complete"}), - JSON.stringify(metadata), - depth, - legacy.created_at, - legacy.created_at - ); - parentId = id; - }); - updateHead.run(parentId, conversation.id); - } - opened.run("COMMIT"); - } catch (error) { - opened.run("ROLLBACK"); - throw error; - } -} - -function migrateConversationHeadsToContentObjects(opened: Database) { - const conversations = opened.query(` - SELECT id, owner_issuer, owner_sub, head_message_id - FROM chat_conversation - WHERE head_message_id IS NOT NULL AND head_message_id NOT LIKE 'sha256:%' - `).all() as Array<{id: string; owner_issuer: string; owner_sub: string; head_message_id: string}>; - if (!conversations.length) return; - opened.run("BEGIN IMMEDIATE"); - try { - const read = opened.query(` - SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, - depth, created_at, completed_at - FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `); - const insert = opened.query(` - INSERT OR IGNORE INTO chat_message_node ( - id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, - completion_json, metadata_json, depth, created_at, completed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const update = opened.query("UPDATE chat_conversation SET head_message_id = ?, head_version = head_version + 1 WHERE id = ?"); - for (const conversation of conversations) { - const path: MessageRow[] = []; - let cursor: string | null = conversation.head_message_id; - while (cursor) { - const row = read.get(cursor, conversation.owner_issuer, conversation.owner_sub) as MessageRow | undefined; - if (!row) throw new Error(`Legacy object ${cursor} is unavailable`); - path.push(row); - cursor = row.parent_message_id; - } - path.reverse(); - const namespace = createHash("sha256").update(`${conversation.owner_issuer}\0${conversation.owner_sub}`).digest("hex").slice(0, 32); - let parentMessageId: string | null = null; - for (const row of path) { - const parsed = parsedMessage(row); - const {id: _legacyId, ...legacyContent} = parsed; - const content: Omit = {...legacyContent, parentMessageId}; - const id: string = `sha256:${createHash("sha256").update(`${namespace}\0${canonicalMessage(content)}`).digest("hex")}`; - insert.run( - id, - conversation.owner_issuer, - conversation.owner_sub, - parentMessageId, - parsed.role, - JSON.stringify(parsed.parts), - JSON.stringify(parsed.origin), - JSON.stringify(parsed.completion), - JSON.stringify(parsed.metadata || {}), - row.depth, - parsed.createdAt, - parsed.completedAt - ); - parentMessageId = id; - } - update.run(parentMessageId, conversation.id); - } - opened.run("COMMIT"); - } catch (error) { - opened.run("ROLLBACK"); - throw error; - } -} - -function getDatabase() { - if (database) return database; - mkdirSync(path.dirname(databasePath), {recursive: true}); - const opened = new Database(databasePath, {create: true, strict: true}); - opened.run(` - PRAGMA journal_mode = WAL; - PRAGMA foreign_keys = ON; - PRAGMA busy_timeout = 5000; - CREATE TABLE IF NOT EXISTS chat_conversation ( - id TEXT PRIMARY KEY, - owner_issuer TEXT NOT NULL, - owner_sub TEXT NOT NULL, - title TEXT NOT NULL, - provider_id TEXT NOT NULL, - model TEXT NOT NULL, - settings_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS chat_conversation_owner_updated - ON chat_conversation (owner_issuer, owner_sub, updated_at DESC); - CREATE TABLE IF NOT EXISTS chat_message ( - conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, - id TEXT NOT NULL, - ordinal INTEGER NOT NULL, - role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), - parts_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (conversation_id, id), - UNIQUE (conversation_id, ordinal) - ); - `); - const conversationColumns = columns(opened, "chat_conversation"); - if (!conversationColumns.some((column) => column.name === "settings_json")) { - opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'"); - } - if (!conversationColumns.some((column) => column.name === "name")) { - opened.run("ALTER TABLE chat_conversation ADD COLUMN name TEXT"); - opened.run("UPDATE chat_conversation SET name = title WHERE name IS NULL"); - } - if (!conversationColumns.some((column) => column.name === "head_message_id")) { - opened.run("ALTER TABLE chat_conversation ADD COLUMN head_message_id TEXT"); - } - if (!conversationColumns.some((column) => column.name === "head_version")) { - opened.run("ALTER TABLE chat_conversation ADD COLUMN head_version INTEGER NOT NULL DEFAULT 0"); - } - if (!conversationColumns.some((column) => column.name === "metadata_version")) { - opened.run("ALTER TABLE chat_conversation ADD COLUMN metadata_version INTEGER NOT NULL DEFAULT 0"); - } - opened.run(` - CREATE TABLE IF NOT EXISTS chat_message_node ( - id TEXT PRIMARY KEY, - owner_issuer TEXT NOT NULL, - owner_sub TEXT NOT NULL, - parent_message_id TEXT REFERENCES chat_message_node(id), - role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), - parts_json TEXT NOT NULL, - origin_json TEXT NOT NULL, - completion_json TEXT NOT NULL, - metadata_json TEXT NOT NULL DEFAULT '{}', - depth INTEGER NOT NULL, - created_at TEXT NOT NULL, - completed_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS chat_message_node_owner_parent - ON chat_message_node (owner_issuer, owner_sub, parent_message_id); - CREATE INDEX IF NOT EXISTS chat_conversation_owner_name - ON chat_conversation (owner_issuer, owner_sub, name); - `); - migrateLegacyMessages(opened); - migrateConversationHeadsToContentObjects(opened); - database = opened; - return opened; -} - -function requiredString(value: unknown, field: string, maximum: number) { - if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`); - return value.trim().slice(0, maximum); -} - -function conversationName(value: unknown) { - if (typeof value !== "string") throw new Error("name must be a string"); - return value.trim().slice(0, 300); -} - -function nullableId(value: unknown, field: string) { - if (value === null || value === undefined || value === "") return null; - if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is invalid`); - return value.trim().slice(0, 160); -} - -function conversationSummary(row: ConversationRow): ConversationSummary { - return { - id: row.id, - name: row.name, - headMessageId: row.head_message_id, - providerId: row.provider_id, - model: row.model, - messageCount: Number(row.message_count), - createdAt: row.created_at, - updatedAt: row.updated_at, - upstreamHeadMessageId: row.head_message_id, - headVersion: Number(row.head_version || 0), - metadataVersion: Number(row.metadata_version || 0) - }; -} - -const conversationSelect = ` - SELECT c.id, COALESCE(c.name, c.title) AS name, c.head_message_id, c.provider_id, c.model, - c.head_version, c.metadata_version, - c.settings_json, c.created_at, c.updated_at, COALESCE(h.depth + 1, 0) AS message_count - FROM chat_conversation c - LEFT JOIN chat_message_node h ON h.id = c.head_message_id -`; - -function ownedConversation(identity: ChatIdentity, id: string) { - return getDatabase().query(`${conversationSelect} - WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ? - `).get(id, identity.issuer, identity.sub) as ConversationRow | undefined; -} - -function ownedMessage(identity: ChatIdentity, id: string) { - return getDatabase().query(` - SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, - depth, created_at, completed_at - FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).get(id, identity.issuer, identity.sub) as MessageRow | undefined; -} - -function parsedMessage(row: MessageRow): StoredChatMessage { - const metadata = JSON.parse(row.metadata_json || "{}") as StoredChatMessage["metadata"]; - return { - id: row.id, - parentMessageId: row.parent_message_id, - role: row.role, - parts: JSON.parse(row.parts_json), - origin: JSON.parse(row.origin_json), - completion: JSON.parse(row.completion_json), - createdAt: row.created_at, - completedAt: row.completed_at, - ...(metadata && Object.keys(metadata).length ? {metadata} : {}) - }; -} - -function messagePath(identity: ChatIdentity, headId: string | null) { - const reversed: StoredChatMessage[] = []; - const seen = new Set(); - let id = headId; - while (id) { - if (seen.has(id) || reversed.length >= 500) throw new Error("Message history is cyclic or too long"); - seen.add(id); - const row = ownedMessage(identity, id); - if (!row) throw new Error("Conversation points to an unavailable message"); - reversed.push(parsedMessage(row)); - id = row.parent_message_id; - } - return reversed.reverse(); -} - -function normalizedParts(value: unknown) { - if (!Array.isArray(value)) throw new Error("parts is required"); - const encoded = JSON.stringify(value); - if (Buffer.byteLength(encoded) > 1024 * 1024) throw new Error("message is too large"); - return JSON.parse(encoded) as StoredChatMessage["parts"]; -} - -function normalizedOrigin(value: unknown, role: StoredChatMessage["role"]): MessageOrigin { - if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as {type?: unknown}).type === "string") { - return JSON.parse(JSON.stringify(value)) as MessageOrigin; - } - if (role === "user") return {type: "user"}; - if (role === "system") return {type: "system", source: "chat"}; - return {type: "legacy"}; -} - -function normalizedCompletion(value: unknown): MessageCompletion { - if (value && typeof value === "object" && !Array.isArray(value) && (value as {status?: unknown}).status === "partial") { - return JSON.parse(JSON.stringify(value)) as MessageCompletion; - } - return {status: "complete"}; -} - -function normalizedMetadata(value: unknown) { - const metadata = value && typeof value === "object" && !Array.isArray(value) ? value : {}; - const encoded = JSON.stringify(metadata); - if (Buffer.byteLength(encoded) > 64 * 1024) throw new Error("message metadata is too large"); - return encoded; -} - -export function listConversations(identity: ChatIdentity): ConversationSummary[] { - return (getDatabase().query(`${conversationSelect} - WHERE c.owner_issuer = ? AND c.owner_sub = ? - ORDER BY c.updated_at DESC LIMIT 100 - `).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationSummary); -} - -export function createConversation(identity: ChatIdentity, input: { - providerId: unknown; - model: unknown; - generationSettings?: unknown; - name?: unknown; - headMessageId?: unknown; -}): Conversation { - const providerId = requiredString(input.providerId, "providerId", 80); - const model = requiredString(input.model, "model", 300); - const name = input.name === undefined ? "" : conversationName(input.name); - const headMessageId = nullableId(input.headMessageId, "headMessageId"); - if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("headMessageId is unavailable"); - const generationSettings = normalizeGenerationSettings(input.generationSettings); - const timestamp = now(); - const id = randomUUID(); - getDatabase().query(` - INSERT INTO chat_conversation ( - id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model, - settings_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp); - return getConversation(identity, id)!; -} - -export function getConversation(identity: ChatIdentity, id: string): Conversation | null { - const row = ownedConversation(identity, id); - if (!row) return null; - return { - ...conversationSummary(row), - generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), - messages: messagePath(identity, row.head_message_id) - }; -} - -export function updateConversation(identity: ChatIdentity, id: string, input: { - providerId?: unknown; - model?: unknown; - generationSettings?: unknown; - name?: unknown; -}) { - const existing = ownedConversation(identity, id); - if (!existing) return null; - const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80); - const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300); - const name = input.name === undefined ? existing.name : conversationName(input.name); - const generationSettings = input.generationSettings === undefined - ? normalizeGenerationSettings(JSON.parse(existing.settings_json || "{}")) - : normalizeGenerationSettings(input.generationSettings); - getDatabase().query(` - UPDATE chat_conversation SET title = ?, name = ?, provider_id = ?, model = ?, settings_json = ?, metadata_version = metadata_version + 1, updated_at = ? - WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).run(name, name, providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub); - return getConversation(identity, id); -} - -export function appendConversationMessage(identity: ChatIdentity, conversationId: string, input: { - id?: unknown; - expectedHeadId?: unknown; - parentMessageId?: unknown; - role?: unknown; - parts?: unknown; - origin?: unknown; - completion?: unknown; - metadata?: unknown; - providerId?: unknown; - model?: unknown; -}) { - const role = String(input.role || "") as StoredChatMessage["role"]; - if (!["system", "user", "assistant"].includes(role)) throw new Error("role is invalid"); - const id = input.id === undefined ? randomUUID() : requiredString(input.id, "id", 160); - const expectedHeadId = nullableId(input.expectedHeadId, "expectedHeadId"); - const parentMessageId = nullableId(input.parentMessageId, "parentMessageId"); - const parts = normalizedParts(input.parts); - const origin = normalizedOrigin(input.origin, role); - const completion = normalizedCompletion(input.completion); - const metadataJson = normalizedMetadata(input.metadata); - const timestamp = now(); - - getDatabase().run("BEGIN IMMEDIATE"); - try { - const existing = ownedConversation(identity, conversationId); - if (!existing) { - getDatabase().run("ROLLBACK"); - return {status: "missing" as const}; - } - if (existing.head_message_id !== expectedHeadId) { - getDatabase().run("ROLLBACK"); - return {status: "conflict" as const, conversation: getConversation(identity, conversationId)!}; - } - const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined; - if (parentMessageId && !parent) throw new Error("parentMessageId is unavailable"); - const depth = parent ? parent.depth + 1 : 0; - const already = ownedMessage(identity, id); - if (already) { - const same = already.parent_message_id === parentMessageId - && already.role === role - && already.parts_json === JSON.stringify(parts) - && already.origin_json === JSON.stringify(origin) - && already.completion_json === JSON.stringify(completion) - && already.metadata_json === metadataJson; - if (!same) throw new Error("message id already exists with different content"); - } else { - getDatabase().query(` - INSERT INTO chat_message_node ( - id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, - completion_json, metadata_json, depth, created_at, completed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - identity.issuer, - identity.sub, - parentMessageId, - role, - JSON.stringify(parts), - JSON.stringify(origin), - JSON.stringify(completion), - metadataJson, - depth, - timestamp, - timestamp - ); - } - const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80); - const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300); - const name = existing.name; - getDatabase().query(` - UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, head_version = head_version + 1, updated_at = ? - WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).run(name, name, id, providerId, model, timestamp, conversationId, identity.issuer, identity.sub); - getDatabase().run("COMMIT"); - return {status: "ok" as const, conversation: getConversation(identity, conversationId)!}; - } catch (error) { - try { getDatabase().run("ROLLBACK"); } catch {} - throw error; - } -} - -function normalizeLegacyMessages(value: unknown): StoredChatMessage[] { - if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries"); - const timestamp = now(); - let parentMessageId: string | null = null; - return value.map((message, index) => { - if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`); - const record = message as Record; - const role = String(record.role) as StoredChatMessage["role"]; - if (!["system", "user", "assistant"].includes(role)) throw new Error(`messages[${index}].role is invalid`); - const normalized: StoredChatMessage = { - id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(), - parentMessageId, - role, - parts: normalizedParts(record.parts), - origin: normalizedOrigin(record.origin, role), - completion: normalizedCompletion(record.completion), - createdAt: typeof record.createdAt === "string" ? record.createdAt : timestamp, - completedAt: typeof record.completedAt === "string" ? record.completedAt : timestamp, - ...(record.metadata ? {metadata: JSON.parse(normalizedMetadata(record.metadata))} : {}) - }; - parentMessageId = normalized.id; - return normalized; - }); -} - -// Compatibility for pending writes created by the previous client. New code commits one immutable message at a time. -export function saveConversationMessages(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; messages: unknown}) { - const existing = ownedConversation(identity, id); - if (!existing) return null; - const messages = normalizeLegacyMessages(input.messages); - let expectedHeadId = existing.head_message_id; - for (const message of messages) { - const already = ownedMessage(identity, message.id); - if (already) { - expectedHeadId = message.id; - continue; - } - const result = appendConversationMessage(identity, id, { - ...message, - expectedHeadId, - providerId: input.providerId, - model: input.model - }); - if (result.status !== "ok") throw new Error("Unable to import legacy conversation path"); - expectedHeadId = message.id; - } - if (!messages.length && existing.head_message_id) { - getDatabase().query(` - UPDATE chat_conversation SET head_message_id = NULL, provider_id = ?, model = ?, updated_at = ? - WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).run(requiredString(input.providerId, "providerId", 80), requiredString(input.model, "model", 300), now(), id, identity.issuer, identity.sub); - } - return getConversation(identity, id); -} - -export function deleteConversation(identity: ChatIdentity, id: string) { - const result = getDatabase().query(` - DELETE FROM chat_conversation WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).run(id, identity.issuer, identity.sub); - return result.changes > 0; -} - -function conversationRef(row: ConversationRow): ConversationRefState { - return { - id: row.id, - name: row.name, - headMessageId: row.head_message_id, - providerId: row.provider_id, - model: row.model, - generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), - headVersion: Number(row.head_version || 0), - metadataVersion: Number(row.metadata_version || 0), - createdAt: row.created_at, - updatedAt: row.updated_at - }; -} - -export function fetchRepository(identity: ChatIdentity, haveObjectIds: unknown): RepositoryFetch { - const have = new Set(Array.isArray(haveObjectIds) - ? haveObjectIds.filter((value): value is string => typeof value === "string").slice(0, 100_000) - : []); - const refs = (getDatabase().query(`${conversationSelect} - WHERE c.owner_issuer = ? AND c.owner_sub = ? ORDER BY c.updated_at DESC - `).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationRef); - const objects = (getDatabase().query(` - SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, - depth, created_at, completed_at - FROM chat_message_node - WHERE owner_issuer = ? AND owner_sub = ? - ORDER BY depth, created_at, id - `).all(identity.issuer, identity.sub) as MessageRow[]) - .filter((row) => !have.has(row.id)) - .map(parsedMessage); - return {refs, objects, fetchedAt: now()}; -} - -export function putRepositoryObjects(identity: ChatIdentity, objects: unknown) { - if (!Array.isArray(objects) || objects.length > 1000) throw new Error("objects must contain at most 1000 entries"); - let inserted = 0; - getDatabase().run("BEGIN IMMEDIATE"); - try { - const insert = getDatabase().query(` - INSERT INTO chat_message_node ( - id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, - completion_json, metadata_json, depth, created_at, completed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const value of objects) { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("repository object is invalid"); - const object = value as StoredChatMessage; - if (typeof object.id !== "string" || !object.id.startsWith("sha256:")) throw new Error("repository object id is invalid"); - if (ownedMessage(identity, object.id)) continue; - const role = object.role; - if (!["system", "user", "assistant"].includes(role)) throw new Error("repository object role is invalid"); - const parentMessageId = nullableId(object.parentMessageId, "parentMessageId"); - const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined; - if (parentMessageId && !parent) throw new Error(`parent object ${parentMessageId} is unavailable`); - insert.run( - object.id, - identity.issuer, - identity.sub, - parentMessageId, - role, - JSON.stringify(normalizedParts(object.parts)), - JSON.stringify(normalizedOrigin(object.origin, role)), - JSON.stringify(normalizedCompletion(object.completion)), - normalizedMetadata(object.metadata), - parent ? parent.depth + 1 : 0, - requiredString(object.createdAt, "createdAt", 80), - requiredString(object.completedAt, "completedAt", 80) - ); - inserted += 1; - } - getDatabase().run("COMMIT"); - } catch (error) { - try { getDatabase().run("ROLLBACK"); } catch {} - throw error; - } - return inserted; -} - -export function pushRepositoryRef(identity: ChatIdentity, update: RepositoryRefUpdate) { - const id = requiredString(update.conversationId, "conversationId", 160); - const expectedHeadMessageId = nullableId(update.expectedHeadMessageId, "expectedHeadMessageId"); - const headMessageId = nullableId(update.headMessageId, "headMessageId"); - const existing = ownedConversation(identity, id); - if (!existing) { - if (expectedHeadMessageId !== null || Number(update.expectedHeadVersion || 0) !== 0 || Number(update.expectedMetadataVersion || 0) !== 0) { - return {status: "conflict" as const, ref: null}; - } - if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable"); - const name = conversationName(update.name); - const providerId = requiredString(update.providerId, "providerId", 80); - const model = requiredString(update.model, "model", 300); - const timestamp = now(); - getDatabase().query(` - INSERT INTO chat_conversation ( - id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model, - settings_json, head_version, metadata_version, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?) - `).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(normalizeGenerationSettings(update.generationSettings)), update.createdAt || timestamp, timestamp); - return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)}; - } - if (existing.head_message_id !== expectedHeadMessageId - || Number(existing.head_version || 0) !== Number(update.expectedHeadVersion || 0) - || Number(existing.metadata_version || 0) !== Number(update.expectedMetadataVersion || 0)) { - return {status: "conflict" as const, ref: conversationRef(existing)}; - } - if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable"); - const name = conversationName(update.name); - const providerId = requiredString(update.providerId, "providerId", 80); - const model = requiredString(update.model, "model", 300); - const headChanged = existing.head_message_id !== headMessageId; - const metadataChanged = existing.name !== name - || existing.provider_id !== providerId - || existing.model !== model - || existing.settings_json !== JSON.stringify(normalizeGenerationSettings(update.generationSettings)); - getDatabase().query(` - UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, settings_json = ?, - head_version = head_version + ?, metadata_version = metadata_version + ?, updated_at = ? - WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).run( - name, - name, - headMessageId, - providerId, - model, - JSON.stringify(normalizeGenerationSettings(update.generationSettings)), - headChanged ? 1 : 0, - metadataChanged ? 1 : 0, - now(), - id, - identity.issuer, - identity.sub - ); - return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)}; -} diff --git a/chat/lib/fullscreen-editor.test.mjs b/chat/lib/fullscreen-editor.test.mjs deleted file mode 100644 index a64e43f..0000000 --- a/chat/lib/fullscreen-editor.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import { - fullscreenEditorCharacterThreshold, - fullscreenEditorLineThreshold, - shouldOpenFullscreenEditor -} from "./fullscreen-editor.ts"; - -describe("fullscreen editor", () => { - test("keeps short messages in the compact composer", () => { - expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold - 1))).toBe(false); - expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold - 1).fill("line").join("\n"))).toBe(false); - }); - - test("opens for a long single-line message", () => { - expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold))).toBe(true); - }); - - test("opens for a message with many lines", () => { - expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold).fill("line").join("\n"))).toBe(true); - }); -}); diff --git a/chat/lib/fullscreen-editor.ts b/chat/lib/fullscreen-editor.ts deleted file mode 100644 index cdecd9f..0000000 --- a/chat/lib/fullscreen-editor.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const fullscreenEditorCharacterThreshold = 600; -export const fullscreenEditorLineThreshold = 8; - -export function shouldOpenFullscreenEditor(value: unknown) { - const text = String(value ?? ""); - const lineCount = text ? text.split(/\r?\n/).length : 0; - return text.length >= fullscreenEditorCharacterThreshold || lineCount >= fullscreenEditorLineThreshold; -} diff --git a/chat/lib/generation-settings.test.mjs b/chat/lib/generation-settings.test.mjs deleted file mode 100644 index 50b2e02..0000000 --- a/chat/lib/generation-settings.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {generationCallOptions, normalizeGenerationSettings} from "./generation-settings.ts"; - -function provider(api) { - return { - id: api, - name: api, - api, - connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null}, - auth: {type: "bearer"}, - headers: {}, - defaultModel: "test-model", - discovery: {type: "openai-models-list", url: "https://example.com/v1/models"}, - builtin: false, - credentialState: "configured", - credentials: [] - }; -} - -describe("generation settings", () => { - test("normalizes user-controlled values", () => { - expect(normalizeGenerationSettings({reasoning: "high", showReasoningSummary: true, temperature: 5, maxOutputTokens: 12.8})).toEqual({ - reasoning: "high", - showReasoningSummary: true, - temperature: 2, - maxOutputTokens: 12 - }); - }); - - test("maps OpenAI reasoning and summary", () => { - expect(generationCallOptions(provider("openai-responses"), { - reasoning: "high", - showReasoningSummary: true, - temperature: null, - maxOutputTokens: 4096 - })).toEqual({ - reasoning: "high", - maxOutputTokens: 4096, - providerOptions: {openai: {reasoningSummary: "auto"}} - }); - }); - - test("maps Anthropic adaptive thinking", () => { - expect(generationCallOptions(provider("anthropic-messages"), { - reasoning: "medium", - showReasoningSummary: true, - temperature: 0.4, - maxOutputTokens: null - })).toEqual({ - reasoning: "medium", - temperature: 0.4, - providerOptions: {anthropic: {thinking: {type: "adaptive", display: "summarized"}}} - }); - }); - - test("keeps auto mode provider-default", () => { - expect(generationCallOptions(provider("openai-completions"), { - reasoning: "auto", - showReasoningSummary: false, - temperature: null, - maxOutputTokens: null - })).toEqual({}); - }); -}); diff --git a/chat/lib/generation-settings.ts b/chat/lib/generation-settings.ts deleted file mode 100644 index f7d91fc..0000000 --- a/chat/lib/generation-settings.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type {ProviderDefinition} from "./provider-types"; - -type JsonValue = null | string | number | boolean | JsonValue[] | {[key: string]: JsonValue}; - -export type ReasoningLevel = "auto" | "none" | "low" | "medium" | "high"; - -export type GenerationSettings = { - reasoning: ReasoningLevel; - showReasoningSummary: boolean; - temperature: number | null; - maxOutputTokens: number | null; -}; - -export const defaultGenerationSettings: GenerationSettings = { - reasoning: "auto", - showReasoningSummary: false, - temperature: null, - maxOutputTokens: null -}; - -export function normalizeGenerationSettings(value: unknown): GenerationSettings { - const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; - const reasoning = ["auto", "none", "low", "medium", "high"].includes(String(input.reasoning)) - ? input.reasoning as ReasoningLevel - : "auto"; - const temperature = typeof input.temperature === "number" && Number.isFinite(input.temperature) - ? Math.min(2, Math.max(0, input.temperature)) - : null; - const maxOutputTokens = typeof input.maxOutputTokens === "number" && Number.isFinite(input.maxOutputTokens) - ? Math.min(1_000_000, Math.max(1, Math.floor(input.maxOutputTokens))) - : null; - return { - reasoning, - showReasoningSummary: input.showReasoningSummary === true, - temperature, - maxOutputTokens - }; -} - -export function generationCallOptions(provider: ProviderDefinition, settings: GenerationSettings) { - const providerOptions: Record = {}; - if (provider.api === "openai-responses") { - providerOptions.openai = {reasoningSummary: settings.showReasoningSummary ? "auto" : null}; - } else if (provider.api === "anthropic-messages" && settings.showReasoningSummary && settings.reasoning !== "none") { - providerOptions.anthropic = {thinking: {type: "adaptive", display: "summarized"}}; - } else if (provider.api === "google-generative-ai" && settings.showReasoningSummary) { - providerOptions.google = {thinkingConfig: {includeThoughts: true}}; - } - return { - ...(settings.reasoning !== "auto" ? {reasoning: settings.reasoning} : {}), - ...(settings.temperature !== null ? {temperature: settings.temperature} : {}), - ...(settings.maxOutputTokens !== null ? {maxOutputTokens: settings.maxOutputTokens} : {}), - ...(Object.keys(providerOptions).length ? {providerOptions} : {}) - }; -} diff --git a/chat/lib/import-title-template.test.mjs b/chat/lib/import-title-template.test.mjs deleted file mode 100644 index 8368089..0000000 --- a/chat/lib/import-title-template.test.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {applyImportTitleTemplate, importFileStem, importSourceFolder} from "./import-title-template.ts"; - -const context = { - title: "Repair auth", - format: "codex", - file: "rollout-123", - folder: "13", - date: "2026-08-13", - model: "gpt-5.6-sol", - provider: "openai", - index: 7 -}; - -describe("import title templates", () => { - test("renders paths and import metadata", () => { - expect(applyImportTitleTemplate("{format}/{date}/{title}", context)).toBe("codex/2026-08-13/Repair auth"); - expect(applyImportTitleTemplate("{index} · {file} · {model}", context)).toBe("7 · rollout-123 · gpt-5.6-sol"); - }); - - test("defaults to the source title and rejects unknown variables", () => { - expect(applyImportTitleTemplate("", context)).toBe("Repair auth"); - expect(() => applyImportTitleTemplate("{project}/{title}", context)).toThrow("未知标题变量"); - }); - - test("extracts source file and folder labels", () => { - expect(importFileStem("backup.xiteng-chat.json")).toBe("backup"); - expect(importFileStem("rollout.jsonl")).toBe("rollout"); - expect(importSourceFolder("sessions.zip / nested / rollout.jsonl")).toBe("nested"); - }); -}); diff --git a/chat/lib/import-title-template.ts b/chat/lib/import-title-template.ts deleted file mode 100644 index 8b4d502..0000000 --- a/chat/lib/import-title-template.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type ImportTitleContext = { - title: string; - format: string; - file: string; - folder: string; - date: string; - model: string; - provider: string; - index: number; -}; - -const placeholders = new Set(["title", "format", "file", "folder", "date", "model", "provider", "index"]); - -export function applyImportTitleTemplate(template: string, context: ImportTitleContext) { - const source = template.trim() || "{title}"; - const unknown = [...source.matchAll(/\{([^{}]+)\}/g)] - .map((match) => match[1]) - .filter((name) => !placeholders.has(name)); - if (unknown.length) throw new Error(`未知标题变量:${[...new Set(unknown)].map((name) => `{${name}}`).join("、")}`); - const values: Record = {...context, index: String(context.index)}; - const rendered = source.replace(/\{([^{}]+)\}/g, (match, name) => values[name] ?? match).trim(); - return rendered || context.title.trim() || "导入的会话"; -} - -export function importFileStem(filename: string) { - return filename.replace(/\.xiteng-chat\.json$/i, "").replace(/\.(?:jsonl|json)$/i, ""); -} - -export function importSourceFolder(source: string) { - const parts = source.replaceAll("\\", "/").split("/").map((part) => part.trim()).filter(Boolean); - return parts.length > 1 ? parts.at(-2)! : ""; -} diff --git a/chat/lib/incremental-markdown-cache.test.mjs b/chat/lib/incremental-markdown-cache.test.mjs deleted file mode 100644 index 001d4c0..0000000 --- a/chat/lib/incremental-markdown-cache.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {IncrementalMarkdownCache} from "./incremental-markdown-cache.ts"; - -describe("incremental markdown cache", () => { - test("never parses a stable prefix again", () => { - const cache = new IncrementalMarkdownCache(); - const calls = []; - const render = (source, type, index, stable) => { - calls.push({source, type, index, stable}); - return {rendered: source.toUpperCase()}; - }; - - cache.render("message", "first", render); - const afterFirst = calls.length; - const closed = cache.render("message", "first\n\n", render); - const afterClosed = calls.length; - const growingTail = cache.render("message", "first\n\nsecond", render); - const longerTail = cache.render("message", "first\n\nsecond grows", render); - - expect(afterFirst).toBe(1); - expect(afterClosed).toBe(2); - expect(closed.blocks[0].stable).toBe(true); - expect(growingTail.reused).toBe(1); - expect(growingTail.parsed).toBe(1); - expect(longerTail.reused).toBe(1); - expect(longerTail.parsed).toBe(1); - expect(calls.filter((call) => call.source === "first")).toHaveLength(2); - }); - - test("assigns stable indexes as blocks become frozen", () => { - const cache = new IncrementalMarkdownCache(); - const render = () => ({}); - const first = cache.render("message", "one\n\ntwo", render); - const second = cache.render("message", "one\n\ntwo\n\nthree", render); - - expect(first.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, false]]); - expect(second.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, true], [2, false]]); - }); - - test("resets when the source is edited before the stable prefix", () => { - const cache = new IncrementalMarkdownCache(); - const render = (source) => ({rendered: source}); - cache.render("message", "one\n\ntwo", render); - const result = cache.render("message", "changed\n\ntwo", render); - expect(result.reused).toBe(0); - expect(result.blocks[0].source).toBe("changed"); - }); - - test("moves cached blocks when a working message receives its immutable id", () => { - const cache = new IncrementalMarkdownCache(); - const render = (source) => ({rendered: source}); - cache.render("working", "first\n\n", render); - - cache.move("working", "immutable"); - const result = cache.render("immutable", "first\n\nsecond", render); - - expect(cache.has("working")).toBe(false); - expect(cache.has("immutable")).toBe(true); - expect(result.reused).toBe(1); - expect(result.blocks.map((block) => block.index)).toEqual([0, 1]); - }); - - test("freezes the final open block when the stream completes", () => { - const cache = new IncrementalMarkdownCache(); - const render = (source) => ({rendered: source}); - cache.render("message", "last paragraph", render); - - const result = cache.render("message", "last paragraph", render, undefined, true); - - expect(result.blocks).toHaveLength(1); - expect(result.blocks[0].stable).toBe(true); - }); -}); diff --git a/chat/lib/incremental-markdown-cache.ts b/chat/lib/incremental-markdown-cache.ts deleted file mode 100644 index 60af364..0000000 --- a/chat/lib/incremental-markdown-cache.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {splitStreamingMarkdown} from "./streaming-markdown"; - -export type IncrementalMarkdownBlock = T & { - source: string; - type: string; - index: number; - stable: boolean; -}; - -export type IncrementalMarkdownResult = { - blocks: Array>; - parsed: number; - reused: number; -}; - -type IncrementalMarkdownState = { - stableSource: string; - stableBlocks: Array>; - nextIndex: number; -}; - -export class IncrementalMarkdownCache { - private readonly states = new Map>(); - - render( - messageId: string, - value: string, - renderBlock: (source: string, type: string, index: number, stable: boolean) => T, - onSplit?: (durationMs: number) => void, - complete = false - ): IncrementalMarkdownResult { - let state = this.states.get(messageId); - if (!state || !value.startsWith(state.stableSource)) { - state = {stableSource: "", stableBlocks: [], nextIndex: 0}; - this.states.set(messageId, state); - } - if (value === state.stableSource) return {blocks: state.stableBlocks, parsed: 0, reused: state.stableBlocks.length}; - - const tail = value.slice(state.stableSource.length); - const startedAt = performance.now(); - const split = splitStreamingMarkdown(tail, complete); - onSplit?.(performance.now() - startedAt); - const renderedTail = split.blocks.map((block, offset) => ({ - ...renderBlock(block.source, block.type, state!.nextIndex + offset, block.stable), - source: block.source, - type: block.type, - index: state!.nextIndex + offset, - stable: block.stable - })); - const newlyStable = renderedTail.filter((_block, index) => split.blocks[index].end <= split.stableOffset); - const previouslyStableCount = state.stableBlocks.length; - if (split.stableOffset > 0) { - state.stableSource += tail.slice(0, split.stableOffset); - state.stableBlocks.push(...newlyStable); - state.nextIndex += newlyStable.length; - } - return { - blocks: [...state.stableBlocks.slice(0, state.stableBlocks.length - newlyStable.length), ...renderedTail], - parsed: renderedTail.length, - reused: previouslyStableCount - }; - } - - delete(messageId: string) { - this.states.delete(messageId); - } - - move(fromMessageId: string, toMessageId: string) { - if (fromMessageId === toMessageId) return; - const state = this.states.get(fromMessageId); - if (!state) return; - this.states.set(toMessageId, state); - this.states.delete(fromMessageId); - } - - has(messageId: string) { - return this.states.has(messageId); - } -} diff --git a/chat/lib/key-vault.ts b/chat/lib/key-vault.ts deleted file mode 100644 index 62e515e..0000000 --- a/chat/lib/key-vault.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {readFileSync} from "node:fs"; - -export type ChatIdentity = {issuer: string; sub: string; username: string; name: string; email: string}; -type HeaderReader = Pick; - -const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093"; -const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site"; -const serviceTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac"; -let serviceToken: string | null = null; - -function loadServiceToken() { - if (!serviceToken) serviceToken = readFileSync(/* turbopackIgnore: true */ serviceTokenFile, "utf8").trim(); - return serviceToken; -} - -export function identityFromHeaders(headers: HeaderReader): ChatIdentity { - const username = headers.get("x-authentik-username")?.trim() || ""; - const sub = headers.get("x-authentik-uid")?.trim() || ""; - if (!username || !sub) { - const error = new Error("Authenticated user context is required"); - Object.assign(error, {statusCode: 401}); - throw error; - } - return { - issuer: authentikIssuer, - sub, - username, - name: headers.get("x-authentik-name")?.trim() || username, - email: headers.get("x-authentik-email")?.trim() || "" - }; -} - -export function keyVaultFetch( - pathname: string, - identity: ChatIdentity, - init: {method?: string; body?: string; headers?: HeadersInit} = {} -) { - const method = init.method || "GET"; - return fetch(new URL(pathname, keyVaultUrl), { - method, - headers: { - "Accept": "application/json", - "Authorization": `Bearer ${loadServiceToken()}`, - ...init.headers, - "X-Key-Vault-Actor-Issuer": identity.issuer, - "X-Key-Vault-Actor-Sub": identity.sub, - "X-Key-Vault-Actor-Username": identity.username - }, - body: init.body, - cache: "no-store", - signal: AbortSignal.timeout(300000) - }); -} diff --git a/chat/lib/llama-cpp-discovery.test.mjs b/chat/lib/llama-cpp-discovery.test.mjs deleted file mode 100644 index e096c99..0000000 --- a/chat/lib/llama-cpp-discovery.test.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {applyBrowserProviderSettings} from "./browser-provider-settings.ts"; -import {discoverProviderModels} from "./provider-connectivity.ts"; - -function llamaProvider() { - return { - id: "llama.cpp", - name: "llama.cpp", - api: "openai-completions", - connection: {type: "frontend", baseUrl: "http://127.0.0.1:8080/v1", proxy: null}, - auth: {type: "none"}, - headers: {}, - defaultModel: "local-model", - discovery: {type: "openai-models-list", url: "http://127.0.0.1:8080/v1/models"}, - builtin: true, - credentialState: "local", - credentials: [] - }; -} - -describe("llama.cpp discovery", () => { - test("applies a browser-local endpoint override", () => { - const provider = applyBrowserProviderSettings(llamaProvider(), {provider: {baseUrl: "http://192.168.4.20:8081/v1/"}}); - expect(provider.connection.baseUrl).toBe("http://192.168.4.20:8081/v1"); - expect(provider.discovery.url).toBe("http://192.168.4.20:8081/v1/models"); - }); - - test("falls back to /props when /v1/models is unavailable", async () => { - const requests = []; - const result = await discoverProviderModels(llamaProvider(), {}, async (input) => { - requests.push(String(input)); - if (String(input).endsWith("/v1/models")) { - return new Response(JSON.stringify({error: "Not Found"}), {status: 404, headers: {"Content-Type": "application/json"}}); - } - return new Response(JSON.stringify({ - model_path: "/models/Qwen3.5-9B-Q4_K_M.gguf", - default_generation_settings: {n_ctx: 32768} - }), {status: 200, headers: {"Content-Type": "application/json"}}); - }); - expect(requests).toEqual(["http://127.0.0.1:8080/v1/models", "http://127.0.0.1:8080/props"]); - expect(result.endpoint).toBe("http://127.0.0.1:8080/props"); - expect(result.models).toEqual([{id: "Qwen3.5-9B-Q4_K_M.gguf", name: "Qwen3.5-9B-Q4_K_M.gguf", ownedBy: "llamacpp", contextWindow: 32768}]); - }); -}); diff --git a/chat/lib/local-credentials.ts b/chat/lib/local-credentials.ts deleted file mode 100644 index 817bbcf..0000000 --- a/chat/lib/local-credentials.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {ProviderSecret} from "./provider-types"; - -export type LocalCredential = { - id: string; - providerId: string; - name: string; - secret: ProviderSecret; - createdAt: string; - updatedAt: string; -}; - -const databaseName = "xiteng-chat-local-vault"; -const storeName = "credentials"; - -function openDatabase() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(databaseName, 1); - request.onupgradeneeded = () => { - const database = request.result; - if (!database.objectStoreNames.contains(storeName)) database.createObjectStore(storeName, {keyPath: "id"}); - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("Unable to open local credential store")); - }); -} - -async function transaction(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest) { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const request = operation(database.transaction(storeName, mode).objectStore(storeName)); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("Local credential operation failed")); - }).finally(() => database.close()); -} - -export function localCredentialId(providerId: string, name = "default") { - return `${providerId}/${name}`; -} - -export function getLocalCredential(providerId: string, name = "default") { - return transaction("readonly", (store) => store.get(localCredentialId(providerId, name))); -} - -export async function listLocalCredentials() { - return transaction("readonly", (store) => store.getAll()); -} - -export async function saveLocalCredential(providerId: string, name: string, secret: ProviderSecret) { - const id = localCredentialId(providerId, name); - const existing = await getLocalCredential(providerId, name); - const timestamp = new Date().toISOString(); - const credential: LocalCredential = { - id, - providerId, - name, - secret, - createdAt: existing?.createdAt || timestamp, - updatedAt: timestamp - }; - await transaction("readwrite", (store) => store.put(credential)); - return credential; -} - -export function deleteLocalCredential(providerId: string, name = "default") { - return transaction("readwrite", (store) => store.delete(localCredentialId(providerId, name))); -} diff --git a/chat/lib/math-markdown.test.mjs b/chat/lib/math-markdown.test.mjs deleted file mode 100644 index b06fd9e..0000000 --- a/chat/lib/math-markdown.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {marked} from "marked"; -import {protectMath, restoreMath} from "./math-markdown.ts"; - -function parse(value) { - const {source, fragments} = protectMath(value); - return {html: restoreMath(marked.parse(source), fragments), fragments}; -} - -describe("math markdown boundary", () => { - test("preserves inline and display TeX delimiters through Markdown", () => { - const {html, fragments} = parse("Inline \\(x^2 + y^2\\) and $z^2$.\n\n$$\\int_0^1 x\\,dx$$\n\n\\[\\frac{a}{b}\\]"); - expect(fragments).toHaveLength(4); - expect(html).toContain("\\(x^2 + y^2\\)"); - expect(html).toContain("$z^2$"); - expect(html).toContain("$$\\int_0^1 x\\,dx$$"); - expect(html).toContain("\\[\\frac{a}{b}\\]"); - expect(html.match(/class="math-fragment"/g)).toHaveLength(4); - }); - - test("does not treat escaped dollars as math", () => { - const {fragments} = protectMath("Price: \\$5"); - expect(fragments).toHaveLength(0); - }); - - test("keeps TeX-looking code as code for MathJax skip tags", () => { - const {html} = parse("`$not_math$` and $math$"); - expect(html).toContain("$not_math$"); - expect(html).toContain("and $math$"); - expect(html.match(/class="math-fragment"/g)).toHaveLength(1); - }); - - test("escapes HTML embedded inside a math fragment", () => { - const {html} = parse("$x y$"); - expect(html).not.toContain(""']/g, (character) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - })[character]!); -} - -export function protectMath(value: string) { - const fragments: string[] = []; - const source = value.replace(mathPattern, (fragment) => { - const index = fragments.push(fragment) - 1; - return `\uE000xiteng-math-${index}\uE001`; - }); - return {source, fragments}; -} - -function mathFragmentKey(fragment: string, index: number) { - let hash = 2166136261; - for (let offset = 0; offset < fragment.length; offset += 1) { - hash ^= fragment.charCodeAt(offset); - hash = Math.imul(hash, 16777619); - } - return `${index}-${fragment.length}-${(hash >>> 0).toString(36)}`; -} - -export function restoreMath(value: string, fragments: string[]) { - return value.replace(tokenPattern, (_token, rawIndex: string, offset: number) => { - const index = Number(rawIndex); - const fragment = fragments[index] || ""; - const codeStart = value.lastIndexOf("", offset); - if (codeStart > codeEnd) return escapeHtml(fragment); - return `${escapeHtml(fragment)}`; - }); -} diff --git a/chat/lib/message-graph.test.mjs b/chat/lib/message-graph.test.mjs deleted file mode 100644 index c02f6cf..0000000 --- a/chat/lib/message-graph.test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {mergeMessageGraph, messageChildrenInGraph, messagePathInGraph, newestBranchTipInGraph, rootEditAlternativesInGraph} from "./message-graph.ts"; - -const node = (id, parentMessageId, createdAt) => ({ - id, parentMessageId, role: id.startsWith("a") ? "assistant" : "user", parts: [], - origin: {type: "legacy"}, completion: {status: "complete"}, createdAt, completedAt: createdAt -}); - -describe("message graph", () => { - const root = node("u1", null, "2026-01-01T00:00:00Z"); - const answerA = node("a1", "u1", "2026-01-01T00:00:01Z"); - const answerB = node("a2", "u1", "2026-01-01T00:00:02Z"); - const followupB = node("u2", "a2", "2026-01-01T00:00:03Z"); - const graph = mergeMessageGraph([root, answerA], [answerB, followupB]); - - test("sorts sibling branches deterministically", () => { - expect(messageChildrenInGraph(graph, "u1").map((message) => message.id)).toEqual(["a1", "a2"]); - }); - - test("builds the selected path without changing a ref", () => { - expect(messagePathInGraph(graph, "u2").map((message) => message.id)).toEqual(["u1", "a2", "u2"]); - }); - - test("uses the current ref for its branch and newest descendants for alternatives", () => { - expect(newestBranchTipInGraph(graph, "a1", new Set(["u1", "a1"]), "a1")).toBe("a1"); - expect(newestBranchTipInGraph(graph, "a2", new Set(["u1", "a1"]), "a1")).toBe("u2"); - }); - - test("does not mix unrelated root messages into first-message edit branches", () => { - const editedRoot = {...node("u3", null, "2026-01-01T00:00:04Z"), origin: {type: "user", sourceMessageId: "u1"}}; - const unrelated = node("u4", null, "2026-01-01T00:00:05Z"); - const roots = mergeMessageGraph([...graph.values()], [editedRoot, unrelated]); - expect(rootEditAlternativesInGraph(roots, "u3").map((message) => message.id)).toEqual(["u1", "u3"]); - }); -}); diff --git a/chat/lib/message-graph.ts b/chat/lib/message-graph.ts deleted file mode 100644 index b492eec..0000000 --- a/chat/lib/message-graph.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type {StoredChatMessage} from "./conversation-types"; - -export function mergeMessageGraph(...groups: StoredChatMessage[][]) { - return new Map(groups.flat().map((message) => [message.id, message])); -} - -export function messagePathInGraph(messages: Map, headMessageId: string | null) { - const reversed: StoredChatMessage[] = []; - const seen = new Set(); - let cursor = headMessageId; - while (cursor) { - if (seen.has(cursor) || reversed.length >= 500) return []; - seen.add(cursor); - const message = messages.get(cursor); - if (!message) return []; - reversed.push(message); - cursor = message.parentMessageId; - } - return reversed.reverse(); -} - -export function messageChildrenInGraph(messages: Map, parentMessageId: string | null) { - return [...messages.values()] - .filter((message) => message.parentMessageId === parentMessageId) - .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); -} - -export function rootEditAlternativesInGraph(messages: Map, messageId: string) { - const roots = [...messages.values()].filter((message) => message.parentMessageId === null); - const related = new Set([messageId]); - let changed = true; - while (changed) { - changed = false; - for (const message of roots) { - const sourceMessageId = "sourceMessageId" in message.origin ? message.origin.sourceMessageId : undefined; - if (!related.has(message.id) && (!sourceMessageId || !related.has(sourceMessageId))) continue; - if (!related.has(message.id)) { related.add(message.id); changed = true; } - if (sourceMessageId && !related.has(sourceMessageId)) { related.add(sourceMessageId); changed = true; } - } - } - return roots - .filter((message) => related.has(message.id)) - .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); -} - -export function newestBranchTipInGraph( - messages: Map, - startId: string, - currentPathIds: Set, - currentHeadMessageId: string | null -) { - if (currentPathIds.has(startId)) return currentHeadMessageId || startId; - let cursor = startId; - const seen = new Set(); - while (!seen.has(cursor)) { - seen.add(cursor); - const children = messageChildrenInGraph(messages, cursor); - if (!children.length) return cursor; - cursor = children.at(-1)!.id; - } - return startId; -} diff --git a/chat/lib/message-object.ts b/chat/lib/message-object.ts deleted file mode 100644 index e772485..0000000 --- a/chat/lib/message-object.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type {StoredChatMessage} from "./conversation-types"; - -function canonicalValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalValue); - if (!value || typeof value !== "object") return value; - return Object.fromEntries(Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, canonicalValue(entry)])); -} - -export function canonicalMessage(message: Omit) { - return JSON.stringify(canonicalValue(message)); -} - -export async function messageObjectId(message: Omit, namespace = "") { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${namespace}\0${canonicalMessage(message)}`)); - return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; -} - -export async function createMessageObject(message: Omit, namespace = ""): Promise { - return {...message, id: await messageObjectId(message, namespace)}; -} - -export async function validMessageObjectId(message: StoredChatMessage, namespace = "") { - if (!message.id.startsWith("sha256:")) return false; - const {id: _id, ...content} = message; - return message.id === await messageObjectId(content, namespace); -} diff --git a/chat/lib/model-display.test.mjs b/chat/lib/model-display.test.mjs deleted file mode 100644 index 303f56b..0000000 --- a/chat/lib/model-display.test.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {compactModelName} from "./model-display.ts"; - -describe("compact model names", () => { - test("hides an Ollama model tag in compact UI", () => { - expect(compactModelName("gemma4:e4b-it-qat")).toBe("gemma4"); - expect(compactModelName("qwen3.5:9b-q4_K_M")).toBe("qwen3.5"); - }); - - test("keeps ordinary model identifiers unchanged", () => { - expect(compactModelName("claude-sonnet-4-6")).toBe("claude-sonnet-4-6"); - expect(compactModelName("Qwen3.5-9B-Q4_K_M.gguf")).toBe("Qwen3.5-9B-Q4_K_M.gguf"); - }); -}); diff --git a/chat/lib/model-display.ts b/chat/lib/model-display.ts deleted file mode 100644 index cbc9272..0000000 --- a/chat/lib/model-display.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function compactModelName(value: unknown) { - const name = String(value ?? "").trim(); - const tagIndex = name.indexOf(":"); - return tagIndex > 0 ? name.slice(0, tagIndex) : name; -} diff --git a/chat/lib/offline-history.ts b/chat/lib/offline-history.ts deleted file mode 100644 index 139d20a..0000000 --- a/chat/lib/offline-history.ts +++ /dev/null @@ -1,763 +0,0 @@ -import type {Conversation, ConversationRefState, ConversationSummary, RepositoryFetch, RepositoryRefUpdate, StoredChatMessage, WorkingItem} from "./conversation-types"; - -const databaseName = "xiteng-chat-offline"; -const databaseVersion = 3; -const activeProfileKey = "xiteng-chat-offline-profile"; - -type CachedProfile = { - id: string; - config: T; - summaries: ConversationSummary[]; - updatedAt: string; - lastFetchAt?: string; -}; - -type CachedConversationRef = Omit & { - cacheKey: string; - profileId: string; - messages?: StoredChatMessage[]; -}; - -type CachedMessage = StoredChatMessage & {cacheKey: string; profileId: string}; -type CachedWorkingItem = WorkingItem & {cacheKey: string; profileId: string}; - -type CachedReflog = { - cacheKey: string; - profileId: string; - conversationId: string; - oldHeadMessageId: string | null; - newHeadMessageId: string | null; - reason: "commit" | "create" | "fetch" | "reset" | "rename" | "delete"; - createdAt: string; -}; - -export type RepositoryOutboxRecord = { - cacheKey: string; - profileId: string; - conversationId: string; - objectIds: string[]; - expectedHeadMessageId: string | null; - expectedHeadVersion: number; - expectedMetadataVersion: number; - createdAt: string; - updatedAt: string; -}; - -export type PendingConversationChange = { - cacheKey: string; - profileId: string; - conversationId: string; - requestPath?: string; - method: "POST" | "PUT" | "PATCH" | "DELETE"; - body?: string; - createdAt: string; -}; - -function openDatabase() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(databaseName, databaseVersion); - request.onupgradeneeded = () => { - const database = request.result; - if (!database.objectStoreNames.contains("profiles")) database.createObjectStore("profiles", {keyPath: "id"}); - if (!database.objectStoreNames.contains("conversations")) { - const conversations = database.createObjectStore("conversations", {keyPath: "cacheKey"}); - conversations.createIndex("profileId", "profileId"); - } - if (!database.objectStoreNames.contains("pending")) { - const pending = database.createObjectStore("pending", {keyPath: "cacheKey"}); - pending.createIndex("profileId", "profileId"); - } - if (!database.objectStoreNames.contains("messages")) { - const messages = database.createObjectStore("messages", {keyPath: "cacheKey"}); - messages.createIndex("profileId", "profileId"); - } - if (!database.objectStoreNames.contains("working")) { - const working = database.createObjectStore("working", {keyPath: "cacheKey"}); - working.createIndex("profileId", "profileId"); - working.createIndex("profileConversation", ["profileId", "conversationId"]); - } - if (!database.objectStoreNames.contains("reflog")) { - const reflog = database.createObjectStore("reflog", {keyPath: "cacheKey"}); - reflog.createIndex("profileConversation", ["profileId", "conversationId"]); - } - if (!database.objectStoreNames.contains("repositoryOutbox")) { - const outbox = database.createObjectStore("repositoryOutbox", {keyPath: "cacheKey"}); - outbox.createIndex("profileId", "profileId"); - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("Unable to open offline history")); - }); -} - -async function transaction(storeName: string, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest) { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const current = database.transaction(storeName, mode); - const request = run(current.objectStore(storeName)); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error(`Offline ${storeName} operation failed`)); - current.oncomplete = () => database.close(); - current.onerror = () => reject(current.error || new Error(`Offline ${storeName} transaction failed`)); - }); -} - -function activeProfileId() { - return window.localStorage.getItem(activeProfileKey) || ""; -} - -function profileCacheKey(profileId: string, id: string) { - return `${profileId}:${id}`; -} - -function normalizedCachedMessage(message: Partial, parentMessageId: string | null, timestamp: string): StoredChatMessage { - const role = message.role || "user"; - return { - id: message.id || crypto.randomUUID(), - parentMessageId: message.parentMessageId === undefined ? parentMessageId : message.parentMessageId, - role, - parts: Array.isArray(message.parts) ? message.parts : [], - origin: message.origin || (role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "legacy-cache"} : {type: "legacy"}), - completion: message.completion || {status: "complete"}, - createdAt: message.createdAt || timestamp, - completedAt: message.completedAt || timestamp, - ...(message.metadata ? {metadata: message.metadata} : {}) - }; -} - -function normalizedConversationSummary(summary: Partial & {title?: unknown}): ConversationSummary | null { - if (typeof summary.id !== "string" || !summary.id) return null; - const timestamp = new Date().toISOString(); - return { - id: summary.id, - name: typeof summary.name === "string" ? summary.name : typeof summary.title === "string" ? summary.title : "", - headMessageId: typeof summary.headMessageId === "string" ? summary.headMessageId : null, - providerId: typeof summary.providerId === "string" ? summary.providerId : "", - model: typeof summary.model === "string" ? summary.model : "", - messageCount: typeof summary.messageCount === "number" && Number.isFinite(summary.messageCount) ? summary.messageCount : 0, - createdAt: typeof summary.createdAt === "string" ? summary.createdAt : timestamp, - updatedAt: typeof summary.updatedAt === "string" ? summary.updatedAt : typeof summary.createdAt === "string" ? summary.createdAt : timestamp, - ...(summary.upstreamHeadMessageId === null || typeof summary.upstreamHeadMessageId === "string" ? {upstreamHeadMessageId: summary.upstreamHeadMessageId} : {}), - ...(typeof summary.headVersion === "number" ? {headVersion: summary.headVersion} : {}), - ...(typeof summary.metadataVersion === "number" ? {metadataVersion: summary.metadataVersion} : {}) - }; -} - -export function activateOfflineProfile(profileId: string) { - window.localStorage.setItem(activeProfileKey, profileId); -} - -export function activeOfflineProfileId() { - return activeProfileId(); -} - -export async function mergeOfflineProfiles(sourceProfileId: string, targetProfileId: string) { - if (!sourceProfileId || sourceProfileId === targetProfileId) return; - const database = await openDatabase(); - await new Promise((resolve, reject) => { - const storeNames = ["profiles", "conversations", "pending", "messages", "working", "reflog", "repositoryOutbox"]; - const current = database.transaction(storeNames, "readwrite"); - const profiles = current.objectStore("profiles"); - const sourceProfileRequest = profiles.get(sourceProfileId); - const targetProfileRequest = profiles.get(targetProfileId); - let sourceProfile: CachedProfile | undefined; - let targetProfile: CachedProfile | undefined; - const profileReady = () => { - if (sourceProfileRequest.readyState !== "done" || targetProfileRequest.readyState !== "done") return; - sourceProfile = sourceProfileRequest.result as CachedProfile | undefined; - targetProfile = targetProfileRequest.result as CachedProfile | undefined; - if (!sourceProfile) return; - const summaries = new Map(); - for (const summary of [...(sourceProfile.summaries || []), ...(targetProfile?.summaries || [])]) { - const existing = summaries.get(summary.id); - if (!existing || summary.updatedAt > existing.updatedAt) summaries.set(summary.id, summary); - } - profiles.put({ - ...(sourceProfile || {}), - ...(targetProfile || {}), - id: targetProfileId, - summaries: [...summaries.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), - updatedAt: new Date().toISOString(), - lastFetchAt: targetProfile?.lastFetchAt || sourceProfile.lastFetchAt - } satisfies CachedProfile); - }; - sourceProfileRequest.onsuccess = profileReady; - targetProfileRequest.onsuccess = profileReady; - - for (const storeName of storeNames.slice(1)) { - const store = current.objectStore(storeName); - const indexName = storeName === "reflog" ? "profileConversation" : "profileId"; - const range = storeName === "reflog" - ? IDBKeyRange.bound([sourceProfileId, ""], [sourceProfileId, "\uffff"]) - : IDBKeyRange.only(sourceProfileId); - const request = store.index(indexName).getAll(range); - request.onsuccess = () => { - for (const raw of request.result as Array>) { - const oldKey = String(raw.cacheKey || ""); - const suffix = oldKey.startsWith(`${sourceProfileId}:`) ? oldKey.slice(sourceProfileId.length) : `:${crypto.randomUUID()}`; - const migrated = {...raw, profileId: targetProfileId, cacheKey: `${targetProfileId}${suffix}`}; - const existingRequest = store.get(migrated.cacheKey as IDBValidKey); - existingRequest.onsuccess = () => { - const existing = existingRequest.result as Record | undefined; - if (!existing || String(raw.updatedAt || raw.createdAt || "") > String(existing.updatedAt || existing.createdAt || "")) store.put(migrated); - }; - } - }; - } - current.oncomplete = () => { database.close(); resolve(); }; - current.onerror = () => { database.close(); reject(current.error || new Error("Unable to merge local repositories")); }; - current.onabort = () => { database.close(); reject(current.error || new Error("Local repository merge was aborted")); }; - }); -} - -export async function cacheChatConfig(profileId: string, config: T) { - activateOfflineProfile(profileId); - const current = await transaction | undefined>("profiles", "readonly", (store) => store.get(profileId)); - const profile: CachedProfile = { - id: profileId, - config, - summaries: current?.summaries || [], - updatedAt: new Date().toISOString(), - lastFetchAt: current?.lastFetchAt - }; - await transaction("profiles", "readwrite", (store) => store.put(profile)); -} - -export async function loadCachedChatConfig(requestedProfileId?: string) { - const profileId = requestedProfileId || activeProfileId(); - if (!profileId) return null; - const profile = await transaction | undefined>("profiles", "readonly", (store) => store.get(profileId)); - return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt, lastFetchAt: profile.lastFetchAt || ""} : null; -} - -export async function cachedLastFetchAt() { - const profileId = activeProfileId(); - if (!profileId) return ""; - const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); - return profile?.lastFetchAt || ""; -} - -export async function recordRepositoryFetch(timestamp: string) { - const profileId = activeProfileId(); - if (!profileId) return; - const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); - if (!profile) return; - await transaction("profiles", "readwrite", (store) => store.put({...profile, lastFetchAt: timestamp})); -} - -export async function cacheConversationSummaries(summaries: ConversationSummary[]) { - const profileId = activeProfileId(); - if (!profileId) return; - const current = await transaction("profiles", "readonly", (store) => store.get(profileId)); - if (!current) return; - const normalized = summaries.map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary)); - await transaction("profiles", "readwrite", (store) => store.put({...current, summaries: normalized, updatedAt: new Date().toISOString()})); -} - -export async function loadCachedConversationSummaries() { - const profileId = activeProfileId(); - if (!profileId) return []; - const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); - return (profile?.summaries || []).map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary)); -} - -export async function cacheConversation(conversation: Conversation) { - const profileId = activeProfileId(); - if (!profileId) return; - let parentMessageId: string | null = null; - for (const candidate of conversation.messages) { - const message = normalizedCachedMessage(candidate, parentMessageId, conversation.updatedAt); - const record: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId}; - await transaction("messages", "readwrite", (store) => store.put(record)); - parentMessageId = message.id; - } - const {messages: _messages, ...summary} = conversation; - const ref: CachedConversationRef = { - ...summary, - upstreamHeadMessageId: conversation.upstreamHeadMessageId === undefined ? conversation.headMessageId : conversation.upstreamHeadMessageId, - headVersion: conversation.headVersion || 0, - metadataVersion: conversation.metadataVersion || 0, - cacheKey: profileCacheKey(profileId, conversation.id), - profileId - }; - await transaction("conversations", "readwrite", (store) => store.put(ref)); - await cacheConversationSummaries([ - summary, - ...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id) - ].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); -} - -async function loadCachedMessage(profileId: string, id: string) { - const record = await transaction("messages", "readonly", (store) => store.get(profileCacheKey(profileId, id))); - if (!record) return null; - const {cacheKey: _cacheKey, profileId: _profileId, ...message} = record; - return message; -} - -export async function loadCachedConversation(id: string) { - const profileId = activeProfileId(); - if (!profileId) return null; - const record = await transaction("conversations", "readonly", (store) => store.get(profileCacheKey(profileId, id))); - if (!record) return null; - const {cacheKey: _cacheKey, profileId: _profileId, messages: legacyMessages, ...conversation} = record; - const normalizedSummary = normalizedConversationSummary(conversation as Partial & {title?: unknown}); - if (!normalizedSummary) return null; - if (legacyMessages) { - const normalized: Conversation = { - ...conversation, - ...normalizedSummary, - headMessageId: normalizedSummary.headMessageId || legacyMessages.at(-1)?.id || null, - messages: legacyMessages.map((message, index) => normalizedCachedMessage(message, index ? legacyMessages[index - 1].id : null, conversation.updatedAt)) - }; - await cacheConversation(normalized); - return normalized; - } - const reversed: StoredChatMessage[] = []; - const seen = new Set(); - let messageId = conversation.headMessageId; - while (messageId) { - if (seen.has(messageId) || reversed.length >= 500) return null; - seen.add(messageId); - const message = await loadCachedMessage(profileId, messageId); - if (!message) return null; - reversed.push(message); - messageId = message.parentMessageId; - } - return {...conversation, ...normalizedSummary, messages: reversed.reverse()}; -} - -export async function removeCachedConversation(id: string) { - const profileId = activeProfileId(); - if (!profileId) return; - await transaction("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id))); - await transaction("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id))); - await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((conversation) => conversation.id !== id)); -} - -export async function deleteLocalConversation(id: string) { - const profileId = activeProfileId(); - if (!profileId) return; - const conversation = await loadCachedConversation(id); - await transaction("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id))); - await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((item) => item.id !== id)); - if (conversation?.upstreamHeadMessageId !== undefined && ((conversation.headVersion || 0) > 0 || (conversation.metadataVersion || 0) > 0)) { - await queueConversationChange({conversationId: id, method: "DELETE"}); - } - await transaction("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id))); -} - -export async function saveWorkingItem(item: WorkingItem) { - const profileId = activeProfileId(); - if (!profileId) return item; - const record: CachedWorkingItem = {...item, cacheKey: profileCacheKey(profileId, item.id), profileId}; - await transaction("working", "readwrite", (store) => store.put(record)); - return item; -} - -export async function listWorkingItems(conversationId?: string) { - const profileId = activeProfileId(); - if (!profileId) return []; - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const current = database.transaction("working", "readonly"); - const store = current.objectStore("working"); - const request = conversationId - ? store.index("profileConversation").getAll([profileId, conversationId]) - : store.index("profileId").getAll(profileId); - request.onsuccess = () => resolve((request.result as CachedWorkingItem[]) - .map(({cacheKey: _cacheKey, profileId: _profileId, ...item}) => item) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); - request.onerror = () => reject(request.error || new Error("Unable to read working items")); - current.oncomplete = () => database.close(); - }); -} - -export async function removeWorkingItem(id: string) { - const profileId = activeProfileId(); - if (!profileId) return; - await transaction("working", "readwrite", (store) => store.delete(profileCacheKey(profileId, id))); -} - -export async function listCachedObjectIds() { - const profileId = activeProfileId(); - if (!profileId) return []; - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const current = database.transaction("messages", "readonly"); - const request = current.objectStore("messages").index("profileId").getAllKeys(IDBKeyRange.only(profileId)); - request.onsuccess = () => resolve(request.result.map((key) => String(key).slice(profileId.length + 1))); - request.onerror = () => reject(request.error || new Error("Unable to list local objects")); - current.oncomplete = () => database.close(); - }); -} - -export async function listCachedMessages() { - const profileId = activeProfileId(); - if (!profileId) return []; - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const current = database.transaction("messages", "readonly"); - const request = current.objectStore("messages").index("profileId").getAll(profileId); - request.onsuccess = () => resolve((request.result as CachedMessage[]).map(({cacheKey: _cacheKey, profileId: _profileId, ...message}) => message)); - request.onerror = () => reject(request.error || new Error("Unable to list local message objects")); - current.oncomplete = () => database.close(); - }); -} - -function repositoryOutboxKey(profileId: string, conversationId: string) { - return `${profileId}:${conversationId}:repository`; -} - -export async function commitLocalMessage(conversationId: string, message: StoredChatMessage) { - const profileId = activeProfileId(); - if (!profileId) throw new Error("Local repository profile is unavailable"); - const database = await openDatabase(); - await new Promise((resolve, reject) => { - const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite"); - const refs = current.objectStore("conversations"); - const objects = current.objectStore("messages"); - const reflog = current.objectStore("reflog"); - const outbox = current.objectStore("repositoryOutbox"); - const refKey = profileCacheKey(profileId, conversationId); - const outboxKey = repositoryOutboxKey(profileId, conversationId); - const refRequest = refs.get(refKey); - refRequest.onsuccess = () => { - const ref = refRequest.result as CachedConversationRef | undefined; - if (!ref) { - current.abort(); - reject(new Error("Local conversation ref is unavailable")); - return; - } - if (ref.headMessageId !== message.parentMessageId) { - current.abort(); - reject(new Error("Local conversation head changed")); - return; - } - const existingOutboxRequest = outbox.get(outboxKey); - existingOutboxRequest.onsuccess = () => { - const timestamp = new Date().toISOString(); - const existing = existingOutboxRequest.result as RepositoryOutboxRecord | undefined; - const object: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId}; - objects.put(object); - refs.put({...ref, headMessageId: message.id, messageCount: ref.messageCount + 1, updatedAt: timestamp}); - const log: CachedReflog = { - cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`, - profileId, - conversationId, - oldHeadMessageId: ref.headMessageId, - newHeadMessageId: message.id, - reason: "commit", - createdAt: timestamp - }; - reflog.put(log); - outbox.put({ - cacheKey: outboxKey, - profileId, - conversationId, - objectIds: [...new Set([...(existing?.objectIds || []), message.id])], - expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null, - expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0, - expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0, - createdAt: existing?.createdAt || timestamp, - updatedAt: timestamp - } satisfies RepositoryOutboxRecord); - }; - }; - current.oncomplete = () => { database.close(); resolve(); }; - current.onerror = () => { database.close(); reject(current.error || new Error("Local commit failed")); }; - current.onabort = () => database.close(); - }); - const conversation = await loadCachedConversation(conversationId); - if (!conversation) throw new Error("Local commit could not be loaded"); - await cacheConversationSummariesFromConversation(conversation); - return conversation; -} - -export async function moveLocalConversationHead(conversationId: string, headMessageId: string | null) { - const profileId = activeProfileId(); - if (!profileId) throw new Error("Local repository profile is unavailable"); - const conversation = await loadCachedConversation(conversationId); - if (!conversation) throw new Error("Local conversation ref is unavailable"); - const targetPath = await messagePathFromCache(profileId, headMessageId); - if (headMessageId !== null && targetPath.at(-1)?.id !== headMessageId) throw new Error("Target message is unavailable in the local graph"); - if (conversation.headMessageId === headMessageId) return conversation; - - const database = await openDatabase(); - await new Promise((resolve, reject) => { - const current = database.transaction(["conversations", "reflog", "repositoryOutbox"], "readwrite"); - const refs = current.objectStore("conversations"); - const reflog = current.objectStore("reflog"); - const outbox = current.objectStore("repositoryOutbox"); - const refKey = profileCacheKey(profileId, conversationId); - const outboxKey = repositoryOutboxKey(profileId, conversationId); - const refRequest = refs.get(refKey); - refRequest.onsuccess = () => { - const ref = refRequest.result as CachedConversationRef | undefined; - if (!ref || ref.headMessageId !== conversation.headMessageId) { - current.abort(); - reject(new Error("Local conversation head changed")); - return; - } - const outboxRequest = outbox.get(outboxKey); - outboxRequest.onsuccess = () => { - const timestamp = new Date().toISOString(); - const existing = outboxRequest.result as RepositoryOutboxRecord | undefined; - refs.put({...ref, headMessageId, messageCount: targetPath.length, updatedAt: timestamp}); - reflog.put({ - cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`, - profileId, - conversationId, - oldHeadMessageId: ref.headMessageId, - newHeadMessageId: headMessageId, - reason: "reset", - createdAt: timestamp - } satisfies CachedReflog); - outbox.put({ - cacheKey: outboxKey, - profileId, - conversationId, - objectIds: existing?.objectIds || [], - expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null, - expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0, - expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0, - createdAt: existing?.createdAt || timestamp, - updatedAt: timestamp - } satisfies RepositoryOutboxRecord); - }; - }; - current.oncomplete = () => { database.close(); resolve(); }; - current.onerror = () => { database.close(); reject(current.error || new Error("Unable to move local conversation head")); }; - current.onabort = () => database.close(); - }); - const updated = await loadCachedConversation(conversationId); - if (!updated) throw new Error("Moved conversation could not be loaded"); - await cacheConversationSummariesFromConversation(updated); - return updated; -} - -async function cacheConversationSummariesFromConversation(conversation: Conversation) { - const summary: ConversationSummary = { - id: conversation.id, - name: conversation.name, - headMessageId: conversation.headMessageId, - providerId: conversation.providerId, - model: conversation.model, - messageCount: conversation.messageCount, - createdAt: conversation.createdAt, - updatedAt: conversation.updatedAt, - upstreamHeadMessageId: conversation.upstreamHeadMessageId, - headVersion: conversation.headVersion, - metadataVersion: conversation.metadataVersion - }; - await cacheConversationSummaries([summary, ...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id)] - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); -} - -export async function createLocalConversation(conversation: Conversation) { - const profileId = activeProfileId(); - if (!profileId) throw new Error("Local repository profile is unavailable"); - const timestamp = new Date().toISOString(); - const local: Conversation = {...conversation, upstreamHeadMessageId: null, headVersion: 0, metadataVersion: 0}; - const database = await openDatabase(); - await new Promise((resolve, reject) => { - const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite"); - const refs = current.objectStore("conversations"); - const objects = current.objectStore("messages"); - const reflog = current.objectStore("reflog"); - const outbox = current.objectStore("repositoryOutbox"); - for (const message of local.messages) objects.put({...message, cacheKey: profileCacheKey(profileId, message.id), profileId} satisfies CachedMessage); - const {messages: _messages, ...summary} = local; - refs.put({...summary, cacheKey: profileCacheKey(profileId, local.id), profileId} satisfies CachedConversationRef); - reflog.put({ - cacheKey: `${profileId}:${local.id}:${timestamp}:${crypto.randomUUID()}`, - profileId, - conversationId: local.id, - oldHeadMessageId: null, - newHeadMessageId: local.headMessageId, - reason: "create", - createdAt: timestamp - } satisfies CachedReflog); - outbox.put({ - cacheKey: repositoryOutboxKey(profileId, local.id), - profileId, - conversationId: local.id, - objectIds: local.messages.filter((message) => message.id.startsWith("sha256:")).map((message) => message.id), - expectedHeadMessageId: null, - expectedHeadVersion: 0, - expectedMetadataVersion: 0, - createdAt: timestamp, - updatedAt: timestamp - } satisfies RepositoryOutboxRecord); - current.oncomplete = () => { database.close(); resolve(); }; - current.onerror = () => { database.close(); reject(current.error || new Error("Unable to create local ref")); }; - }); - await cacheConversationSummariesFromConversation(local); - return local; -} - -export async function queueLocalRefUpdate(conversation: Conversation) { - const profileId = activeProfileId(); - if (!profileId) throw new Error("Local repository profile is unavailable"); - await cacheConversation(conversation); - const key = repositoryOutboxKey(profileId, conversation.id); - const existing = await transaction("repositoryOutbox", "readonly", (store) => store.get(key)); - const timestamp = new Date().toISOString(); - const record: RepositoryOutboxRecord = { - cacheKey: key, - profileId, - conversationId: conversation.id, - objectIds: existing?.objectIds || [], - expectedHeadMessageId: existing?.expectedHeadMessageId ?? conversation.upstreamHeadMessageId ?? null, - expectedHeadVersion: existing?.expectedHeadVersion ?? conversation.headVersion ?? 0, - expectedMetadataVersion: existing?.expectedMetadataVersion ?? conversation.metadataVersion ?? 0, - createdAt: existing?.createdAt || timestamp, - updatedAt: timestamp - }; - await transaction("repositoryOutbox", "readwrite", (store) => store.put(record)); - return conversation; -} - -export async function repositoryPushPayload() { - const profileId = activeProfileId(); - if (!profileId) return {repositoryId: "", objects: [] as StoredChatMessage[], refs: [] as RepositoryRefUpdate[]}; - const database = await openDatabase(); - const outbox = await new Promise((resolve, reject) => { - const current = database.transaction("repositoryOutbox", "readonly"); - const request = current.objectStore("repositoryOutbox").index("profileId").getAll(profileId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - current.oncomplete = () => database.close(); - }); - const objects: StoredChatMessage[] = []; - const refs: RepositoryRefUpdate[] = []; - for (const pending of outbox) { - const conversation = await loadCachedConversation(pending.conversationId); - if (!conversation) continue; - for (const id of pending.objectIds) { - const object = await loadCachedMessage(profileId, id); - if (object) objects.push(object); - } - refs.push({ - conversationId: conversation.id, - expectedHeadMessageId: pending.expectedHeadMessageId, - expectedHeadVersion: pending.expectedHeadVersion, - expectedMetadataVersion: pending.expectedMetadataVersion, - headMessageId: conversation.headMessageId, - name: conversation.name, - providerId: conversation.providerId, - model: conversation.model, - generationSettings: conversation.generationSettings, - createdAt: conversation.createdAt, - updatedAt: conversation.updatedAt - }); - } - return {repositoryId: profileId, objects, refs}; -} - -export async function applyRepositoryFetch(repository: RepositoryFetch) { - const profileId = activeProfileId(); - if (!profileId) return; - for (const object of repository.objects) { - await transaction("messages", "readwrite", (store) => store.put({...object, cacheKey: profileCacheKey(profileId, object.id), profileId})); - } - for (const remote of repository.refs) { - const local = await loadCachedConversation(remote.id); - const pending = await transaction("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, remote.id))); - const canFastForward = !local || (!pending && local.headMessageId === (local.upstreamHeadMessageId ?? local.headMessageId)); - const headMessageId = canFastForward ? remote.headMessageId : local!.headMessageId; - const messages = await messagePathFromCache(profileId, headMessageId); - const conversation: Conversation = { - id: remote.id, - name: pending && local ? local.name : remote.name, - headMessageId, - upstreamHeadMessageId: remote.headMessageId, - providerId: pending && local ? local.providerId : remote.providerId, - model: pending && local ? local.model : remote.model, - generationSettings: pending && local ? local.generationSettings : remote.generationSettings, - headVersion: remote.headVersion, - metadataVersion: remote.metadataVersion, - messageCount: messages.length, - createdAt: remote.createdAt, - updatedAt: pending && local ? local.updatedAt : remote.updatedAt, - messages - }; - await cacheConversation(conversation); - } - const remoteIds = new Set(repository.refs.map((ref) => ref.id)); - for (const local of await loadCachedConversationSummaries()) { - if (remoteIds.has(local.id)) continue; - const pending = await transaction("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, local.id))); - if (!pending && ((local.headVersion || 0) > 0 || (local.metadataVersion || 0) > 0)) await removeCachedConversation(local.id); - } - await recordRepositoryFetch(repository.fetchedAt); -} - -async function messagePathFromCache(profileId: string, headMessageId: string | null) { - const reversed: StoredChatMessage[] = []; - const seen = new Set(); - let id = headMessageId; - while (id) { - if (seen.has(id) || reversed.length >= 500) throw new Error("Local object history is cyclic or too long"); - seen.add(id); - const object = await loadCachedMessage(profileId, id); - if (!object) throw new Error(`Local object ${id} is unavailable`); - reversed.push(object); - id = object.parentMessageId; - } - return reversed.reverse(); -} - -export async function applyRepositoryPushResults(results: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>) { - const profileId = activeProfileId(); - if (!profileId) return; - for (const result of results) { - const local = await loadCachedConversation(result.conversationId); - if (!local || !result.ref) continue; - if (result.status === "ok") { - await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion}); - const key = repositoryOutboxKey(profileId, result.conversationId); - if (local.headMessageId === result.ref.headMessageId) { - await transaction("repositoryOutbox", "readwrite", (store) => store.delete(key)); - } else { - const pending = await transaction("repositoryOutbox", "readonly", (store) => store.get(key)); - if (pending) await transaction("repositoryOutbox", "readwrite", (store) => store.put({ - ...pending, - expectedHeadMessageId: result.ref!.headMessageId, - expectedHeadVersion: result.ref!.headVersion, - expectedMetadataVersion: result.ref!.metadataVersion - })); - } - } else { - await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion}); - } - } -} - -export async function queueConversationChange(change: Omit) { - const profileId = activeProfileId(); - if (!profileId) return; - const record: PendingConversationChange = { - ...change, - cacheKey: `${profileId}:${change.conversationId}:${change.method}:${change.requestPath || "conversation"}`, - profileId, - createdAt: new Date().toISOString() - }; - await transaction("pending", "readwrite", (store) => store.put(record)); -} - -export async function listPendingConversationChanges() { - const profileId = activeProfileId(); - if (!profileId) return []; - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const current = database.transaction("pending", "readonly"); - const request = current.objectStore("pending").index("profileId").getAll(profileId); - request.onsuccess = () => resolve(request.result.sort((left, right) => left.createdAt.localeCompare(right.createdAt))); - request.onerror = () => reject(request.error || new Error("Unable to read pending history changes")); - current.oncomplete = () => database.close(); - }); -} - -export async function removePendingConversationChange(cacheKey: string) { - await transaction("pending", "readwrite", (store) => store.delete(cacheKey)); -} diff --git a/chat/lib/profile-types.ts b/chat/lib/profile-types.ts deleted file mode 100644 index 213aa9c..0000000 --- a/chat/lib/profile-types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ChatProfile = { - username: string; - name: string; - email: string; -}; diff --git a/chat/lib/provider-connectivity.ts b/chat/lib/provider-connectivity.ts deleted file mode 100644 index e466c5c..0000000 --- a/chat/lib/provider-connectivity.ts +++ /dev/null @@ -1,188 +0,0 @@ -import {applyProviderAuthentication} from "./provider-model"; -import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types"; - -type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; -type JsonRecord = Record; - - -function accountEndpoints(provider: ProviderDefinition) { - const baseUrl = provider.connection.baseUrl.replace(/\/+$/, ""); - const url = new URL(baseUrl); - if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"]; - if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`]; - if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`]; - if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`]; - if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`]; - return []; -} - -async function responseText(response: Response, maximum = 4 * 1024 * 1024) { - const text = await response.text(); - if (text.length > maximum) throw new Error("Provider response is too large"); - return text; -} - -function jsonRecord(value: unknown): JsonRecord | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null; -} - -function publicMetadata(value: unknown, depth = 0): unknown { - if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; - if (depth >= 3) return undefined; - if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined); - const record = jsonRecord(value); - if (!record) return undefined; - return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined)); -} - -function selectedMetadata(record: JsonRecord, pattern: RegExp) { - return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined)); -} - -export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] { - const root = jsonRecord(payload); - if (!root) return []; - const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : []; - return source.slice(0, 300).map((value) => { - const model = jsonRecord(value); - if (!model) return null; - const rawId = model.id || model.name || model.model; - if (typeof rawId !== "string" || !rawId.trim()) return null; - const id = rawId.replace(/^models\//, ""); - const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i); - return { - id, - name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id, - ...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}), - ...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}), - ...(Object.keys(pricing).length ? {pricing} : {}) - }; - }).filter((model): model is ProviderModel => model !== null); -} - -function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] { - const root = jsonRecord(payload); - if (!root) return []; - const modelPath = typeof root.model_path === "string" ? root.model_path : ""; - const modelAlias = typeof root.model_alias === "string" ? root.model_alias : ""; - const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel; - const generationSettings = jsonRecord(root.default_generation_settings); - const contextWindow = Number(generationSettings?.n_ctx); - return [{ - id, - name: id, - ownedBy: "llamacpp", - ...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {}) - }]; -} - -function responseRateLimits(response: Response) { - return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name))); -} - -async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) { - const response = await providerFetch(endpoint, { - method: "GET", - headers, - signal: AbortSignal.timeout(timeoutMs) - }); - const text = await responseText(response); - let payload: unknown = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = {preview: text.slice(0, 1000)}; - } - return {response, payload}; -} - -async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) { - const endpoint = provider.discovery.url; - const headers = applyProviderAuthentication(provider, secret, { - "Accept": "application/json", - ...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {}) - }); - const startedAt = performance.now(); - const initial = await fetchJson(providerFetch, endpoint, headers); - let {response, payload} = initial; - let resolvedEndpoint = endpoint; - let models = response.ok ? normalizeDiscoveredModels(payload) : []; - if (provider.id === "llama.cpp" && models.length === 0) { - const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`; - try { - const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000); - const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : []; - if (propsModels.length > 0) { - response = props.response; - payload = props.payload; - resolvedEndpoint = propsEndpoint; - models = propsModels; - } - } catch { - // Preserve the primary /v1/models error when the compatibility probe also fails. - } - } - const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); - if (!response.ok) { - const root = jsonRecord(payload); - const detail = root?.preview || root?.error || root?.message; - const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`); - Object.assign(error, {statusCode: response.status}); - throw error; - } - return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models}; -} - -export async function discoverProviderModels( - provider: ProviderDefinition, - secret: ProviderSecret, - providerFetch: ProviderFetch = fetch -) { - const probe = await modelProbe(provider, secret, providerFetch); - return { - endpoint: probe.endpoint, - status: probe.response.status, - latencyMs: probe.latencyMs, - models: probe.models - }; -} - -export async function testProviderConnectivity( - provider: ProviderDefinition, - secret: ProviderSecret, - providerFetch: ProviderFetch = fetch -) { - const probe = await modelProbe(provider, secret, providerFetch); - const {endpoint, headers, response, payload, latencyMs, models} = probe; - const root = jsonRecord(payload) || {}; - const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i); - const rateLimits = responseRateLimits(response); - const accountUrls = accountEndpoints(provider); - let accountProbe: unknown = null; - for (const accountUrl of accountUrls) { - try { - const probe = await fetchJson(providerFetch, accountUrl, headers, 5000); - if (!probe.response.ok) continue; - accountProbe = { - endpoint: accountUrl, - status: probe.response.status, - data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload) - }; - break; - } catch { - // Account metadata is best-effort and must not fail a successful model probe. - } - } - - return { - ok: true, - status: response.status, - latencyMs, - endpoint, - modelCount: models.length, - models, - account: Object.keys(account).length ? account : null, - accountProbe, - rateLimits: Object.keys(rateLimits).length ? rateLimits : null - }; -} diff --git a/chat/lib/provider-model.ts b/chat/lib/provider-model.ts deleted file mode 100644 index f043dc6..0000000 --- a/chat/lib/provider-model.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {ProviderDefinition, ProviderSecret} from "./provider-types"; - -type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; - -export function applyProviderAuthentication( - provider: ProviderDefinition, - secret: ProviderSecret, - inputHeaders?: HeadersInit -) { - const headers = new Headers(inputHeaders); - headers.delete("authorization"); - headers.delete("x-api-key"); - headers.delete("x-goog-api-key"); - for (const [name, value] of Object.entries(provider.headers || {})) headers.set(name, value); - for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value); - const apiKey = secret.provider?.apiKey || ""; - if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`); - if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey); - return headers; -} - -export async function createProviderModel( - provider: ProviderDefinition, - secret: ProviderSecret, - modelId: string, - providerFetch: ProviderFetch -) { - const authenticatedFetch: ProviderFetch = (input, init = {}) => providerFetch(input, { - ...init, - headers: applyProviderAuthentication(provider, secret, init.headers) - }); - // Provider API comes from the runtime Registry; dynamic imports keep unused SDKs out of the initial client chunk. - if (provider.api === "openai-completions") { - const {createOpenAICompatible} = await import("@ai-sdk/openai-compatible"); - return createOpenAICompatible({ - name: provider.id, - baseURL: provider.connection.baseUrl, - fetch: authenticatedFetch as typeof fetch - }).chatModel(modelId); - } - if (provider.api === "openai-responses") { - const {createOpenAI} = await import("@ai-sdk/openai"); - return createOpenAI({ - name: provider.id, - baseURL: provider.connection.baseUrl, - apiKey: secret.provider?.apiKey || "browser-managed", - fetch: authenticatedFetch as typeof fetch - }).responses(modelId); - } - if (provider.api === "anthropic-messages") { - const {createAnthropic} = await import("@ai-sdk/anthropic"); - return createAnthropic({ - name: provider.id, - baseURL: provider.connection.baseUrl, - apiKey: secret.provider?.apiKey || "browser-managed", - fetch: authenticatedFetch as typeof fetch - }).messages(modelId); - } - const {createGoogleGenerativeAI} = await import("@ai-sdk/google"); - return createGoogleGenerativeAI({ - name: provider.id, - baseURL: provider.connection.baseUrl, - apiKey: secret.provider?.apiKey || "browser-managed", - fetch: authenticatedFetch as typeof fetch - }).chat(modelId); -} diff --git a/chat/lib/provider-types.ts b/chat/lib/provider-types.ts deleted file mode 100644 index a5eb187..0000000 --- a/chat/lib/provider-types.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai"; - -export type ProviderModel = { - id: string; - name: string; - contextWindow?: number; - maxTokens?: number; - input?: string[]; - reasoning?: boolean; - ownedBy?: string; - pricing?: Record; -}; - -export type ProviderDiscovery = { - type: "openai-models-list" | "anthropic-models-list" | "google-models-list"; - url: string; -}; - -export type ProviderDefinition = { - id: string; - name: string; - api: ProviderApi; - connection: { - type: "frontend" | "backend"; - baseUrl: string; - proxy: null | {type: "relay" | "http" | "https" | "socks5"; url: string}; - }; - auth: {type: "bearer" | "header" | "none"; header?: string}; - headers: Record; - defaultModel: string; - discovery: ProviderDiscovery; - builtin: boolean; - credentialState: "configured" | "missing" | "local"; - credentials: Array<{id: string; providerId: string; name: string; fingerprint: string}>; -}; - -export type ProviderSecret = { - provider?: {apiKey?: string; headers?: Record; baseUrl?: string}; - proxy?: {username?: string; password?: string; token?: string}; -}; - -export type ResolvedBackendProvider = { - provider: ProviderDefinition; - credential: {id: string; name: string; secret: ProviderSecret}; -}; diff --git a/chat/lib/repository-push-batches.test.mjs b/chat/lib/repository-push-batches.test.mjs deleted file mode 100644 index 258465e..0000000 --- a/chat/lib/repository-push-batches.test.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {repositoryPushBatches} from "./repository-push-batches.ts"; - -describe("repository push batches", () => { - test("keeps requests within server limits", () => { - const objects = Array.from({length: 2001}, (_, index) => ({id: `sha256:${index}`})); - const refs = Array.from({length: 201}, (_, index) => ({conversationId: String(index)})); - const batches = repositoryPushBatches({repositoryId: "local:test", objects, refs}); - expect(batches.map((batch) => [batch.objects.length, batch.refs.length])).toEqual([ - [1000, 0], [1000, 0], [1, 0], [0, 100], [0, 100], [0, 1] - ]); - }); - - test("deduplicates shared message objects", () => { - const object = {id: "sha256:shared"}; - const batches = repositoryPushBatches({repositoryId: "local:test", objects: [object, object], refs: []}); - expect(batches).toHaveLength(1); - expect(batches[0].objects).toHaveLength(1); - }); -}); diff --git a/chat/lib/repository-push-batches.ts b/chat/lib/repository-push-batches.ts deleted file mode 100644 index cb8f9b0..0000000 --- a/chat/lib/repository-push-batches.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type {RepositoryRefUpdate, StoredChatMessage} from "./conversation-types"; - -export type RepositoryPushPayload = { - repositoryId: string; - objects: StoredChatMessage[]; - refs: RepositoryRefUpdate[]; -}; - -function chunks(items: T[], size: number) { - const result: T[][] = []; - for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size)); - return result; -} - -export function repositoryPushBatches(payload: RepositoryPushPayload) { - const objectBatches = chunks([...new Map(payload.objects.map((object) => [object.id, object])).values()], 1000) - .map((objects) => ({repositoryId: payload.repositoryId, objects, refs: [] as RepositoryRefUpdate[]})); - const refBatches = chunks(payload.refs, 100) - .map((refs) => ({repositoryId: payload.repositoryId, objects: [] as StoredChatMessage[], refs})); - return [...objectBatches, ...refBatches]; -} diff --git a/chat/lib/response-metadata.test.mjs b/chat/lib/response-metadata.test.mjs deleted file mode 100644 index 9cf8363..0000000 --- a/chat/lib/response-metadata.test.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {responseMetadata} from "./response-metadata.ts"; - -describe("response metadata", () => { - test("calculates output token throughput", () => { - const metadata = responseMetadata("rust.cat", "gpt-test", performance.now() - 2000, 40); - expect(metadata.providerId).toBe("rust.cat"); - expect(metadata.model).toBe("gpt-test"); - expect(metadata.durationMs).toBeGreaterThanOrEqual(1900); - expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(19); - expect(metadata.tokensPerSecond).toBeLessThanOrEqual(21); - }); - - test("keeps speed unavailable when provider omits token usage", () => { - const metadata = responseMetadata("local", "model", performance.now() - 100, undefined); - expect(metadata.outputTokens).toBeNull(); - expect(metadata.tokensPerSecond).toBeNull(); - }); - - test("falls back to estimated tokens when provider output is absent", () => { - const metadata = responseMetadata("local", "model", performance.now() - 2000, undefined, 80); - expect(metadata.outputTokens).toBe(80); - expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(39); - expect(metadata.tokensPerSecond).toBeLessThanOrEqual(41); - }); -}); diff --git a/chat/lib/response-metadata.ts b/chat/lib/response-metadata.ts deleted file mode 100644 index 609083c..0000000 --- a/chat/lib/response-metadata.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {ResponseMetadata} from "./conversation-types"; - -function normalizeTokenCount(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value); - if (typeof value === "bigint" && value >= 0n) return Number(value); - if (typeof value === "string") { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed >= 0) return Math.round(parsed); - } - return null; -} - -export function responseMetadata( - providerId: string, - model: string, - startedAt: number, - outputTokens: number | undefined | null, - fallbackOutputTokens?: number | undefined | null -): ResponseMetadata { - const durationMs = Math.max(1, Math.round(performance.now() - startedAt)); - const normalizedTokens = normalizeTokenCount(outputTokens) ?? normalizeTokenCount(fallbackOutputTokens); - const tokensPerSecond = normalizedTokens === null - ? null - : Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10; - return {providerId, model, durationMs, outputTokens: normalizedTokens, tokensPerSecond}; -} diff --git a/chat/lib/server-provider-fetch.ts b/chat/lib/server-provider-fetch.ts deleted file mode 100644 index d83a2b1..0000000 --- a/chat/lib/server-provider-fetch.ts +++ /dev/null @@ -1,16 +0,0 @@ -import nodeFetch from "node-fetch"; -import {ProxyAgent} from "proxy-agent"; -import type {ProviderDefinition, ProviderSecret} from "./provider-types"; - -export function createServerProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) { - const proxy = provider.connection.proxy; - if (!proxy) return fetch; - const proxyUrl = new URL(proxy.url); - if (secret.proxy?.username) proxyUrl.username = secret.proxy.username; - if (secret.proxy?.password) proxyUrl.password = secret.proxy.password; - const agent = new ProxyAgent({getProxyForUrl: () => proxyUrl.toString()}); - return async (input: RequestInfo | URL, init?: RequestInit) => { - const response = await nodeFetch(String(input), {...init, agent} as never); - return response as unknown as Response; - }; -} diff --git a/chat/lib/session-transfer.test.mjs b/chat/lib/session-transfer.test.mjs deleted file mode 100644 index a52b784..0000000 --- a/chat/lib/session-transfer.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import { - currentPath, - detectSessionTransferFormat, - parseSessionTransfer, - serializeSessionTransfer, - serializeXitengArchive -} from "./session-transfer.ts"; - -const lines = (values) => `${values.map((value) => JSON.stringify(value)).join("\n")}\n`; - -describe("session transfer formats", () => { - test("imports Codex rollout records without duplicate event messages", () => { - const text = lines([ - {type: "session_meta", timestamp: "2026-08-13T00:00:00Z", payload: {id: "11111111-1111-4111-8111-111111111111", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"}}, - {type: "turn_context", timestamp: "2026-08-13T00:00:00Z", payload: {model: "gpt-5.6-sol"}}, - {type: "response_item", timestamp: "2026-08-13T00:00:01Z", payload: {type: "message", role: "user", content: [{type: "input_text", text: "hello"}]}}, - {type: "event_msg", timestamp: "2026-08-13T00:00:01Z", payload: {type: "user_message", message: "hello"}}, - {type: "response_item", timestamp: "2026-08-13T00:00:02Z", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "hi"}]}}, - {type: "event_msg", timestamp: "2026-08-13T00:00:02Z", payload: {type: "agent_message", message: "hi"}} - ]); - const parsed = parseSessionTransfer(text, "rollout.jsonl"); - expect(parsed.format).toBe("codex"); - expect(parsed.nodes.map((node) => node.role)).toEqual(["user", "assistant"]); - expect(parsed.sessions[0].model).toBe("gpt-5.6-sol"); - }); - - test("preserves Claude parent branches", () => { - const text = lines([ - {type: "user", sessionId: "s1", uuid: "u1", parentUuid: null, timestamp: "2026-08-13T00:00:00Z", message: {role: "user", content: "question"}}, - {type: "assistant", sessionId: "s1", uuid: "a1", parentUuid: "u1", timestamp: "2026-08-13T00:00:01Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "first"}]}}, - {type: "assistant", sessionId: "s1", uuid: "a2", parentUuid: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "second"}]}}, - {type: "custom-title", sessionId: "s1", customTitle: "branched"} - ]); - const parsed = parseSessionTransfer(text, "claude.jsonl"); - expect(parsed.nodes.map((node) => node.parentSourceId)).toEqual([null, "u1", "u1"]); - expect(parsed.sessions[0].headSourceId).toBe("a2"); - expect(parsed.sessions[0].name).toBe("branched"); - }); - - test("imports current OMP title slot and tree", () => { - const text = lines([ - {type: "title", v: 1, title: "OMP task", updatedAt: "2026-08-13T00:00:00Z", pad: ""}, - {type: "session", version: 3, id: "omp-1", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"}, - {type: "message", id: "u1", parentId: null, timestamp: "2026-08-13T00:00:01Z", message: {role: "user", content: [{type: "text", text: "hello"}]}}, - {type: "message", id: "a1", parentId: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", content: [{type: "text", text: "hi"}]}} - ]); - const parsed = parseSessionTransfer(text, "omp.jsonl"); - expect(parsed.sessions[0].name).toBe("OMP task"); - expect(currentPath(parsed.nodes, "a1").map((node) => node.sourceId)).toEqual(["u1", "a1"]); - }); - - test("exports parseable Codex, Claude and OMP JSONL", () => { - const document = { - format: "xiteng", - sessions: [{sourceId: "22222222-2222-4222-8222-222222222222", name: "round trip", headSourceId: "a1", providerId: "openai", model: "gpt-5.6", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:02Z"}], - nodes: [ - {sourceId: "u1", parentSourceId: null, role: "user", parts: [{type: "text", text: "hello"}], createdAt: "2026-08-13T00:00:01Z", completedAt: "2026-08-13T00:00:01Z"}, - {sourceId: "a1", parentSourceId: "u1", role: "assistant", parts: [{type: "reasoning", text: "brief"}, {type: "text", text: "hi"}], createdAt: "2026-08-13T00:00:02Z", completedAt: "2026-08-13T00:00:02Z"} - ] - }; - for (const format of ["codex", "claude", "omp"]) { - const exported = serializeSessionTransfer(document, format); - expect(detectSessionTransferFormat(exported)).toBe(format); - const imported = parseSessionTransfer(exported); - expect(imported.nodes.some((node) => node.parts.some((part) => part.type === "text" && part.text === "hi"))).toBe(true); - } - }); - - test("native archive keeps graph objects and working items", () => { - const text = serializeXitengArchive([ - {id: "c1", name: "native", headMessageId: "m1", providerId: "p", model: "m", messageCount: 1, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:01Z", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, messages: []} - ], [ - {id: "m1", parentMessageId: null, role: "user", parts: [{type: "text", text: "draft"}], origin: {type: "user"}, completion: {status: "complete"}, createdAt: "2026-08-13T00:00:00Z", completedAt: "2026-08-13T00:00:00Z"} - ], []); - const parsed = parseSessionTransfer(text, "backup.xiteng-chat.json"); - expect(parsed.format).toBe("xiteng"); - expect(parsed.sessions).toHaveLength(1); - expect(parsed.nodes).toHaveLength(1); - }); -}); diff --git a/chat/lib/session-transfer.ts b/chat/lib/session-transfer.ts deleted file mode 100644 index ab9265a..0000000 --- a/chat/lib/session-transfer.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type {Conversation, StoredChatMessage, WorkingItem} from "./conversation-types"; -import {defaultGenerationSettings, normalizeGenerationSettings, type GenerationSettings} from "./generation-settings"; - -export type SessionTransferFormat = "xiteng" | "codex" | "claude" | "omp"; - -export type TransferNode = { - sourceId: string; - parentSourceId: string | null; - role: StoredChatMessage["role"]; - parts: StoredChatMessage["parts"]; - createdAt: string; - completedAt: string; - completion?: StoredChatMessage["completion"]; - origin?: StoredChatMessage["origin"]; - metadata?: StoredChatMessage["metadata"]; -}; - -export type TransferSession = { - sourceId: string; - name: string; - headSourceId: string | null; - providerId: string; - model: string; - generationSettings: GenerationSettings; - createdAt: string; - updatedAt: string; -}; - -export type TransferDocument = { - format: SessionTransferFormat; - sessions: TransferSession[]; - nodes: TransferNode[]; - workingItems?: WorkingItem[]; -}; - -type JsonRecord = Record; - -function isRecord(value: unknown): value is JsonRecord { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function stringValue(value: unknown, fallback = "") { - return typeof value === "string" ? value : fallback; -} - -function isoTimestamp(value: unknown, fallback = new Date().toISOString()) { - if (typeof value === "number" && Number.isFinite(value)) { - const milliseconds = value < 10_000_000_000 ? value * 1000 : value; - return new Date(milliseconds).toISOString(); - } - if (typeof value === "string" && Number.isFinite(Date.parse(value))) return new Date(value).toISOString(); - return fallback; -} - -function jsonLines(text: string) { - const values: JsonRecord[] = []; - for (const [index, line] of text.split(/\r?\n/).entries()) { - if (!line.trim()) continue; - try { - const value: unknown = JSON.parse(line); - if (isRecord(value)) values.push(value); - } catch { - throw new Error(`JSONL 第 ${index + 1} 行无法解析`); - } - } - return values; -} - -function contentParts(value: unknown, textTypes = ["text", "input_text", "output_text"]): StoredChatMessage["parts"] { - if (typeof value === "string") return value ? [{type: "text", text: value}] : []; - if (!Array.isArray(value)) return []; - const parts: StoredChatMessage["parts"] = []; - for (const candidate of value) { - if (!isRecord(candidate)) continue; - if (textTypes.includes(stringValue(candidate.type)) && typeof candidate.text === "string") { - parts.push({type: "text", text: candidate.text}); - } else if (candidate.type === "thinking" && typeof candidate.thinking === "string") { - parts.push({type: "reasoning", text: candidate.thinking, ...(typeof candidate.signature === "string" ? {signature: candidate.signature} : {})}); - } else if (candidate.type === "image" && isRecord(candidate.source)) { - const data = stringValue(candidate.source.data); - const mediaType = stringValue(candidate.source.media_type); - if (data && mediaType) parts.push({type: "image", data, mimeType: mediaType}); - } else if (candidate.type === "input_image" && typeof candidate.image_url === "string") { - parts.push({type: "image-url", url: candidate.image_url, ...(typeof candidate.detail === "string" ? {detail: candidate.detail} : {})}); - } - } - return parts; -} - -function firstText(nodes: TransferNode[]) { - for (const node of nodes) { - if (node.role !== "user") continue; - const text = node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join("").trim(); - if (text && !/^<(environment_context|permissions instructions)>/i.test(text) && !/^# AGENTS\.md instructions/i.test(text)) return text.replace(/\s+/g, " ").slice(0, 80); - } - return "导入的会话"; -} - -function newestLeaf(nodes: TransferNode[]) { - const parents = new Set(nodes.map((node) => node.parentSourceId).filter((id): id is string => Boolean(id))); - return [...nodes].filter((node) => !parents.has(node.sourceId)).sort((left, right) => right.completedAt.localeCompare(left.completedAt))[0]?.sourceId || nodes.at(-1)?.sourceId || null; -} - -function transferNode(sourceId: string, parentSourceId: string | null, role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string, extra: Partial = {}): TransferNode { - return { - sourceId, - parentSourceId, - role, - parts, - createdAt: timestamp, - completedAt: timestamp, - completion: {status: "complete"}, - origin: role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "session-import"} : {type: "legacy"}, - ...extra - }; -} - -function parseXiteng(value: JsonRecord): TransferDocument { - if (value.type !== "xiteng-chat-archive" || value.version !== 1 || !Array.isArray(value.conversations) || !Array.isArray(value.objects)) { - throw new Error("不支持的 Xiteng Chat 备份版本"); - } - const nodes: TransferNode[] = []; - for (const candidate of value.objects) { - if (!isRecord(candidate) || typeof candidate.id !== "string" || !["system", "user", "assistant"].includes(String(candidate.role)) || !Array.isArray(candidate.parts)) continue; - nodes.push({ - sourceId: candidate.id, - parentSourceId: typeof candidate.parentMessageId === "string" ? candidate.parentMessageId : null, - role: candidate.role as StoredChatMessage["role"], - parts: candidate.parts as StoredChatMessage["parts"], - origin: isRecord(candidate.origin) ? candidate.origin as StoredChatMessage["origin"] : {type: "legacy"}, - completion: isRecord(candidate.completion) ? candidate.completion as StoredChatMessage["completion"] : {status: "complete"}, - createdAt: isoTimestamp(candidate.createdAt), - completedAt: isoTimestamp(candidate.completedAt, isoTimestamp(candidate.createdAt)), - ...(isRecord(candidate.metadata) ? {metadata: candidate.metadata as StoredChatMessage["metadata"]} : {}) - }); - } - const sessions: TransferSession[] = value.conversations.flatMap((candidate) => { - if (!isRecord(candidate) || typeof candidate.id !== "string") return []; - return [{ - sourceId: candidate.id, - name: stringValue(candidate.name), - headSourceId: typeof candidate.headMessageId === "string" ? candidate.headMessageId : null, - providerId: stringValue(candidate.providerId, "imported"), - model: stringValue(candidate.model, "imported"), - generationSettings: normalizeGenerationSettings(candidate.generationSettings), - createdAt: isoTimestamp(candidate.createdAt), - updatedAt: isoTimestamp(candidate.updatedAt) - }]; - }); - return {format: "xiteng", sessions, nodes, workingItems: Array.isArray(value.workingItems) ? value.workingItems as WorkingItem[] : []}; -} - -function parseCodex(records: JsonRecord[]): TransferDocument { - const metadata = records.find((record) => record.type === "session_meta" && isRecord(record.payload)); - const sessionPayload = metadata && isRecord(metadata.payload) ? metadata.payload : {}; - const sessionId = stringValue(sessionPayload.id, crypto.randomUUID()); - let model = "codex"; - let name = ""; - let parentSourceId: string | null = null; - let ordinal = 0; - const nodes: TransferNode[] = []; - const canonicalUserText = new Set(); - const canonicalAssistantText = new Set(); - for (const record of records) { - if (record.type !== "response_item" || !isRecord(record.payload) || record.payload.type !== "message") continue; - const text = contentParts(record.payload.content).filter((part) => part.type === "text").map((part) => String(part.text || "")).join(""); - if (record.payload.role === "user") canonicalUserText.add(text); - if (record.payload.role === "assistant") canonicalAssistantText.add(text); - } - const pushNode = (role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string) => { - if (!parts.length) return; - const sourceId = `codex-${sessionId}-${++ordinal}`; - nodes.push(transferNode(sourceId, parentSourceId, role, parts, timestamp)); - parentSourceId = sourceId; - }; - for (const record of records) { - const timestamp = isoTimestamp(record.timestamp, isoTimestamp(sessionPayload.timestamp)); - if (!isRecord(record.payload)) continue; - const payload = record.payload; - if (record.type === "turn_context" && typeof payload.model === "string") model = payload.model; - if (record.type === "response_item") { - if (payload.type === "message" && (payload.role === "user" || payload.role === "assistant")) { - const parts = contentParts(payload.content); - const text = parts.filter((part) => part.type === "text").map((part) => String(part.text || "")).join("").trim(); - if (payload.role !== "user" || !/^<(environment_context|permissions instructions)>/i.test(text)) pushNode(payload.role, parts, timestamp); - } else if (payload.type === "reasoning") { - const parts = [...(Array.isArray(payload.summary) ? payload.summary : []), ...(Array.isArray(payload.content) ? payload.content : [])] - .flatMap((item) => isRecord(item) && typeof item.text === "string" ? [{type: "reasoning", text: item.text}] : []); - pushNode("assistant", parts, timestamp); - } else if (["function_call", "custom_tool_call", "web_search_call", "tool_search_call"].includes(String(payload.type))) { - const callId = stringValue(payload.call_id, stringValue(payload.id, `call-${ordinal + 1}`)); - const toolName = stringValue(payload.name, payload.type === "web_search_call" ? "web_search" : payload.type === "tool_search_call" ? "tool_search" : "unknown"); - let argumentsValue: unknown = payload.type === "custom_tool_call" ? payload.input : payload.arguments ?? payload.action; - if (typeof argumentsValue === "string") { - try { argumentsValue = JSON.parse(argumentsValue); } catch { argumentsValue = {input: argumentsValue}; } - } - pushNode("assistant", [{type: "tool-call", id: callId, name: toolName, arguments: isRecord(argumentsValue) ? argumentsValue : {}}], timestamp); - } else if (["function_call_output", "custom_tool_call_output", "tool_search_output"].includes(String(payload.type))) { - pushNode("assistant", [{type: "tool-result", toolCallId: stringValue(payload.call_id), content: payload.output ?? payload.tools, isError: payload.status === "failed"}], timestamp); - } - } else if (record.type === "event_msg") { - if (payload.type === "thread_name_updated" && typeof payload.thread_name === "string") name = payload.thread_name; - if (payload.type === "user_message" && typeof payload.message === "string" && !canonicalUserText.has(payload.message)) pushNode("user", [{type: "text", text: payload.message}], timestamp); - if (payload.type === "agent_message" && typeof payload.message === "string" && !canonicalAssistantText.has(payload.message)) pushNode("assistant", [{type: "text", text: payload.message}], timestamp); - } - } - const createdAt = isoTimestamp(sessionPayload.timestamp, nodes[0]?.createdAt); - return { - format: "codex", - nodes, - sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: parentSourceId, providerId: "openai", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}] - }; -} - -function nearestRetainedParent(sourceParentId: string | null, sourceParents: Map, retained: Map) { - const seen = new Set(); - let cursor = sourceParentId; - while (cursor && !seen.has(cursor)) { - seen.add(cursor); - const retainedId = retained.get(cursor); - if (retainedId) return retainedId; - cursor = sourceParents.get(cursor) || null; - } - return null; -} - -function parseClaude(records: JsonRecord[]): TransferDocument { - const sourceParents = new Map(); - for (const record of records) if (typeof record.uuid === "string") sourceParents.set(record.uuid, typeof record.parentUuid === "string" ? record.parentUuid : null); - const retained = new Map(); - const nodes: TransferNode[] = []; - let sessionId = ""; - let name = ""; - let model = "claude"; - for (const [index, record] of records.entries()) { - sessionId ||= stringValue(record.sessionId); - if (record.type === "custom-title") name = stringValue(record.customTitle, name); - if (record.type === "ai-title" && !name) name = stringValue(record.aiTitle, name); - if ((record.type !== "user" && record.type !== "assistant") || record.isSidechain === true || record.isMeta === true || !isRecord(record.message)) continue; - const sourceUuid = stringValue(record.uuid, `claude-line-${index + 1}`); - let parentSourceId = nearestRetainedParent(typeof record.parentUuid === "string" ? record.parentUuid : null, sourceParents, retained); - const timestamp = isoTimestamp(record.timestamp); - const converted: TransferNode[] = []; - if (record.type === "assistant") { - model = stringValue(record.message.model, model); - const parts = contentParts(record.message.content); - if (Array.isArray(record.message.content)) { - for (const block of record.message.content) { - if (!isRecord(block) || block.type !== "tool_use") continue; - parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.input) ? block.input : {}}); - } - } - if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "assistant", parts, timestamp)); - } else { - const toolResults = Array.isArray(record.message.content) ? record.message.content.filter((block) => isRecord(block) && block.type === "tool_result") : []; - if (toolResults.length) { - for (const block of toolResults) { - if (!isRecord(block)) continue; - converted.push(transferNode(`${sourceUuid}-tool-result-${converted.length}`, parentSourceId, "assistant", [{type: "tool-result", toolCallId: stringValue(block.tool_use_id), content: block.content, isError: block.is_error === true}], timestamp)); - parentSourceId = converted.at(-1)!.sourceId; - } - } else { - const parts = contentParts(record.message.content); - if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "user", parts, timestamp)); - } - } - for (const node of converted) nodes.push(node); - const tail = converted.at(-1)?.sourceId; - if (tail) retained.set(sourceUuid, tail); - } - sessionId ||= crypto.randomUUID(); - const createdAt = nodes[0]?.createdAt || new Date().toISOString(); - return {format: "claude", nodes, sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: newestLeaf(nodes), providerId: "anthropic", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]}; -} - -function ompMessageParts(message: JsonRecord) { - const parts = contentParts(message.content); - if (Array.isArray(message.content)) { - for (const block of message.content) { - if (!isRecord(block)) continue; - if (block.type === "toolCall") parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.arguments) ? block.arguments : {}}); - } - } - return parts; -} - -function parseOmp(records: JsonRecord[]): TransferDocument { - const header = records.find((record) => record.type === "session"); - if (!header) throw new Error("OMP JSONL 缺少 session header"); - const titleSlot = records.find((record) => record.type === "title"); - const titleChanges = records.filter((record) => record.type === "title_change"); - const sourceParents = new Map(); - for (const record of records) if (typeof record.id === "string" && record.type !== "session") sourceParents.set(record.id, typeof record.parentId === "string" ? record.parentId : null); - const retained = new Map(); - const nodes: TransferNode[] = []; - let model = "omp"; - for (const record of records) { - if (record.type === "model_change" && typeof record.model === "string") model = record.model.includes("/") ? record.model.slice(record.model.indexOf("/") + 1) : record.model; - if (record.type !== "message" || typeof record.id !== "string" || !isRecord(record.message)) continue; - const parentSourceId = nearestRetainedParent(typeof record.parentId === "string" ? record.parentId : null, sourceParents, retained); - const timestamp = isoTimestamp(record.timestamp); - const role = record.message.role; - let parts: StoredChatMessage["parts"] = []; - if (role === "toolResult") parts = [{type: "tool-result", toolCallId: stringValue(record.message.toolCallId), toolName: stringValue(record.message.toolName, "unknown"), content: record.message.content, isError: record.message.isError === true}]; - else parts = ompMessageParts(record.message); - if (!parts.length || (role !== "user" && role !== "assistant" && role !== "toolResult")) continue; - nodes.push(transferNode(record.id, parentSourceId, role === "toolResult" ? "assistant" : role, parts, timestamp)); - retained.set(record.id, record.id); - } - const createdAt = isoTimestamp(header.timestamp, nodes[0]?.createdAt); - const latestTitle = titleChanges.at(-1); - const name = stringValue(latestTitle?.title, stringValue(titleSlot?.title, stringValue(header.title, firstText(nodes)))); - return {format: "omp", nodes, sessions: [{sourceId: stringValue(header.id, crypto.randomUUID()), name, headSourceId: nodes.at(-1)?.sourceId || null, providerId: model.includes("claude") ? "anthropic" : model.includes("gpt") || model.includes("codex") ? "openai" : "imported", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]}; -} - -export function detectSessionTransferFormat(text: string, filename = ""): SessionTransferFormat { - const trimmed = text.trimStart(); - if (trimmed.startsWith("{")) { - try { - const firstLine = JSON.parse(trimmed.split(/\r?\n/, 1)[0]) as unknown; - if (isRecord(firstLine)) { - if (firstLine.type === "xiteng-chat-archive") return "xiteng"; - if (firstLine.type === "session_meta") return "codex"; - if (firstLine.type === "session" || firstLine.type === "title") return "omp"; - if (typeof firstLine.sessionId === "string" || typeof firstLine.uuid === "string" || ["user", "assistant", "custom-title", "ai-title"].includes(String(firstLine.type))) return "claude"; - } - } catch {} - } - const lower = filename.toLowerCase(); - if (lower.endsWith(".xiteng-chat.json")) return "xiteng"; - throw new Error("无法识别会话格式;请选择 Xiteng Chat JSON 或 Codex / Claude Code / OMP JSONL"); -} - -export function parseSessionTransfer(text: string, filename = ""): TransferDocument { - const format = detectSessionTransferFormat(text, filename); - if (format === "xiteng") { - const value: unknown = JSON.parse(text); - if (!isRecord(value)) throw new Error("Xiteng Chat 备份不是 JSON 对象"); - return parseXiteng(value); - } - const records = jsonLines(text); - if (format === "codex") return parseCodex(records); - if (format === "claude") return parseClaude(records); - return parseOmp(records); -} - -function nodeTextParts(node: TransferNode) { - return node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => ({type: node.role === "assistant" ? "output_text" : "input_text", text: String(part.text)})); -} - -function assistantReasoningParts(node: TransferNode) { - return node.parts.filter((part) => part.type === "reasoning" && typeof part.text === "string").map((part) => String(part.text)); -} - -function portableUuid(value: string) { - const match = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(value); - return match?.[0] || crypto.randomUUID(); -} - -function jsonl(records: JsonRecord[]) { - return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`; -} - -function serializeCodex(document: TransferDocument) { - const session = document.sessions[0]; - if (!session) throw new Error("没有可导出的会话"); - const id = portableUuid(session.sourceId); - const records: JsonRecord[] = [{type: "session_meta", timestamp: session.createdAt, payload: {id, timestamp: session.createdAt, cwd: "/workspace", originator: "xiteng-chat", cli_version: "0.147.0", source: "cli", model_provider: session.providerId}}]; - records.push({type: "turn_context", timestamp: session.createdAt, payload: {cwd: "/workspace", model: session.model}}); - for (const node of currentPath(document.nodes, session.headSourceId)) { - const text = nodeTextParts(node); - if (text.length) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "message", role: node.role, content: text, ...(node.role === "assistant" ? {phase: "final_answer"} : {})}}); - for (const reasoning of assistantReasoningParts(node)) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "reasoning", summary: [{type: "summary_text", text: reasoning}]}}); - for (const part of node.parts) { - if (part.type === "tool-call") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call", call_id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: JSON.stringify(isRecord(part.arguments) ? part.arguments : {})}}); - if (part.type === "tool-result") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call_output", call_id: stringValue(part.toolCallId), output: part.content ?? ""}}); - } - } - if (session.name) records.push({type: "event_msg", timestamp: session.updatedAt, payload: {type: "thread_name_updated", thread_name: session.name}}); - return jsonl(records); -} - -function claudeContent(node: TransferNode) { - const content: JsonRecord[] = []; - for (const part of node.parts) { - if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text}); - if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text, signature: stringValue(part.signature)}); - if (part.type === "tool-call") content.push({type: "tool_use", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), input: isRecord(part.arguments) ? part.arguments : {}}); - } - return content; -} - -function serializeClaude(document: TransferDocument) { - const session = document.sessions[0]; - if (!session) throw new Error("没有可导出的会话"); - const sessionId = portableUuid(session.sourceId); - const idMap = new Map(document.nodes.map((node) => [node.sourceId, portableUuid(node.sourceId)])); - const records: JsonRecord[] = []; - for (const node of document.nodes) { - const common = {sessionId, uuid: idMap.get(node.sourceId), parentUuid: node.parentSourceId ? idMap.get(node.parentSourceId) || null : null, timestamp: node.createdAt, cwd: "/workspace", version: "2.1.81", gitBranch: ""}; - const toolResults = node.parts.filter((part) => part.type === "tool-result"); - const content = claudeContent(node); - if (toolResults.length) { - records.push({...common, type: "user", message: {role: "user", content: toolResults.map((part) => ({type: "tool_result", tool_use_id: stringValue(part.toolCallId), content: part.content ?? "", is_error: part.isError === true}))}}); - } else if (node.role === "user") { - records.push({...common, type: "user", message: {role: "user", content}}); - } else if (node.role === "assistant" && content.length) { - records.push({...common, type: "assistant", message: {id: `msg_${idMap.get(node.sourceId)?.replaceAll("-", "")}`, type: "message", role: "assistant", model: session.model, content, stop_reason: "end_turn", stop_sequence: null, usage: {input_tokens: 0, output_tokens: 0}}}); - } - } - if (session.name) records.push({type: "custom-title", customTitle: session.name, sessionId}); - return jsonl(records); -} - -function ompContent(node: TransferNode) { - const content: JsonRecord[] = []; - for (const part of node.parts) { - if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text}); - if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text}); - if (part.type === "tool-call") content.push({type: "toolCall", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: isRecord(part.arguments) ? part.arguments : {}}); - } - return content; -} - -function serializeOmp(document: TransferDocument) { - const session = document.sessions[0]; - if (!session) throw new Error("没有可导出的会话"); - const idMap = new Map(document.nodes.map((node, index) => [node.sourceId, `xt${(index + 1).toString(36).padStart(6, "0")}`])); - const records: JsonRecord[] = [{type: "session", version: 3, id: portableUuid(session.sourceId), timestamp: session.createdAt, cwd: "/workspace", title: session.name, titleSource: "user"}]; - let modelParent: string | null = null; - if (session.model) { - modelParent = "xtmodel0"; - records.push({type: "model_change", id: modelParent, parentId: null, timestamp: session.createdAt, model: `${session.providerId}/${session.model}`}); - } - for (const node of document.nodes) { - const parentId = node.parentSourceId ? idMap.get(node.parentSourceId) || null : modelParent; - const toolResult = node.parts.find((part) => part.type === "tool-result"); - if (toolResult) { - records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: "toolResult", toolCallId: stringValue(toolResult.toolCallId), toolName: stringValue(toolResult.toolName, "unknown"), content: toolResult.content ?? "", isError: toolResult.isError === true, timestamp: Date.parse(node.createdAt)}}); - } else { - records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: node.role, content: ompContent(node), ...(node.role === "assistant" ? {api: "openai-responses", provider: session.providerId, model: session.model, usage: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0}}, stopReason: "stop"} : {}), timestamp: Date.parse(node.createdAt)}}); - } - } - return jsonl(records); -} - -export function currentPath(nodes: TransferNode[], headSourceId: string | null) { - const byId = new Map(nodes.map((node) => [node.sourceId, node])); - const reversed: TransferNode[] = []; - const seen = new Set(); - let cursor = headSourceId; - while (cursor && !seen.has(cursor)) { - seen.add(cursor); - const node = byId.get(cursor); - if (!node) break; - reversed.push(node); - cursor = node.parentSourceId; - } - return reversed.reverse(); -} - -export function serializeSessionTransfer(document: TransferDocument, format: Exclude) { - if (format === "codex") return serializeCodex(document); - if (format === "claude") return serializeClaude(document); - return serializeOmp(document); -} - -export function serializeXitengArchive(conversations: Conversation[], objects: StoredChatMessage[], workingItems: WorkingItem[]) { - return JSON.stringify({ - type: "xiteng-chat-archive", - version: 1, - exportedAt: new Date().toISOString(), - conversations: conversations.map(({messages: _messages, ...conversation}) => conversation), - objects, - workingItems - }, null, 2); -} - -export function conversationTransferDocument(conversation: Conversation, nodes: StoredChatMessage[]): TransferDocument { - return { - format: "xiteng", - sessions: [{sourceId: conversation.id, name: conversation.name, headSourceId: conversation.headMessageId, providerId: conversation.providerId, model: conversation.model, generationSettings: conversation.generationSettings, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt}], - nodes: nodes.map((message) => ({sourceId: message.id, parentSourceId: message.parentMessageId, role: message.role, parts: message.parts, origin: message.origin, completion: message.completion, createdAt: message.createdAt, completedAt: message.completedAt, ...(message.metadata ? {metadata: message.metadata} : {})})) - }; -} diff --git a/chat/lib/streaming-markdown.test.mjs b/chat/lib/streaming-markdown.test.mjs deleted file mode 100644 index 28c18a4..0000000 --- a/chat/lib/streaming-markdown.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import {describe, expect, test} from "bun:test"; -import {splitStreamingMarkdown} from "./streaming-markdown.ts"; - -describe("streaming markdown blocks", () => { - test("freezes a paragraph after its blank-line boundary", () => { - const open = splitStreamingMarkdown("first paragraph"); - expect(open.blocks.map((block) => block.stable)).toEqual([false]); - expect(open.stableOffset).toBe(0); - - const closed = splitStreamingMarkdown("first paragraph\n\n"); - expect(closed.blocks.map((block) => block.stable)).toEqual([true]); - expect(closed.stableOffset).toBe("first paragraph\n\n".length); - }); - - test("keeps only the trailing paragraph mutable", () => { - const source = "first paragraph\n\nsecond paragraph"; - const result = splitStreamingMarkdown(source); - expect(result.blocks.map(({source, stable}) => [source, stable])).toEqual([ - ["first paragraph", true], - ["second paragraph", false] - ]); - expect(source.slice(0, result.stableOffset)).toBe("first paragraph\n\n"); - }); - - test("keeps a list mutable because another item can merge after a blank line", () => { - const result = splitStreamingMarkdown("- first\n\n"); - expect(result.blocks).toHaveLength(1); - expect(result.blocks[0].stable).toBe(false); - expect(result.stableOffset).toBe(0); - }); - - test("freezes fenced code only after its closing fence", () => { - expect(splitStreamingMarkdown("```ts\nconst x = 1").blocks[0].stable).toBe(false); - expect(splitStreamingMarkdown("```ts\nconst x = 1\n```\n").blocks[0].stable).toBe(true); - }); - - test("freezes a standalone display formula when its delimiter closes", () => { - expect(splitStreamingMarkdown("$$\\int_0^1 x dx").blocks[0].stable).toBe(false); - expect(splitStreamingMarkdown("$$\\int_0^1 x dx$$").blocks[0].stable).toBe(true); - expect(splitStreamingMarkdown("\\[\\frac{a}{b}\\]").blocks[0].stable).toBe(true); - }); - - test("marks every block stable after the stream completes", () => { - const result = splitStreamingMarkdown("paragraph without trailing newline", true); - expect(result.blocks[0].stable).toBe(true); - expect(result.stableOffset).toBe("paragraph without trailing newline".length); - }); -}); diff --git a/chat/lib/streaming-markdown.ts b/chat/lib/streaming-markdown.ts deleted file mode 100644 index cfe6895..0000000 --- a/chat/lib/streaming-markdown.ts +++ /dev/null @@ -1,77 +0,0 @@ -import {marked} from "marked"; - -export type StreamingMarkdownBlock = { - source: string; - type: string; - start: number; - end: number; - stable: boolean; -}; - -export type StreamingMarkdownSplit = { - blocks: StreamingMarkdownBlock[]; - stableOffset: number; -}; - -type BlockToken = {type: string; raw: string}; - -function hasClosedFence(raw: string) { - const opening = raw.match(/^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/); - if (!opening) return false; - const marker = opening[2]; - const character = marker[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const closing = new RegExp(`(?:^|\\n) {0,3}${character}{${marker.length},}[ \\t]*(?:\\n|$)`); - return closing.test(raw.slice(opening[0].length)); -} - -function isClosedDisplayMath(raw: string) { - const value = raw.trim(); - return /^\$\$(?!\$)[\s\S]*?(? = []; - let offset = 0; - for (const token of tokens) { - const start = offset; - offset += token.raw.length; - records.push({...token, start, end: offset}); - } - - const semanticIndexes = records.flatMap((token, index) => token.type === "space" ? [] : [index]); - const lastSemanticIndex = semanticIndexes.at(-1) ?? -1; - let firstUnstableStart = source.length; - const blocks: StreamingMarkdownBlock[] = []; - - for (const index of semanticIndexes) { - const token = records[index]; - const stable = complete || index < lastSemanticIndex || isSelfClosing(token, records[index + 1]); - if (!stable && firstUnstableStart === source.length) firstUnstableStart = token.start; - if (token.type === "def") continue; - blocks.push({ - source: token.raw, - type: token.type, - start: token.start, - end: token.end, - stable - }); - } - - if (complete || firstUnstableStart === source.length) return {blocks, stableOffset: source.length}; - return {blocks, stableOffset: firstUnstableStart}; -} diff --git a/chat/package.json b/chat/package.json deleted file mode 100644 index eebe024..0000000 --- a/chat/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "xiteng-chat", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "bun --hot src/server.ts", - "typecheck": "tsc --noEmit", - "build": "bun run typecheck && bun run build.ts", - "start": "bun src/server.ts", - "test": "bun test", - "test:history": "bun run build && bun history.test.mjs" - }, - "dependencies": { - "@ai-sdk/anthropic": "4.0.36", - "@ai-sdk/google": "4.0.39", - "@ai-sdk/openai": "4.0.36", - "@ai-sdk/openai-compatible": "3.0.28", - "ai": "7.0.58", - "dompurify": "3.2.6", - "fflate": "0.8.2", - "marked": "15.0.12", - "node-fetch": "3.3.2", - "proxy-agent": "8.0.2" - }, - "devDependencies": { - "@types/bun": "1.3.5", - "mathjax": "4.1.3", - "typescript": "5.9.3" - } -} diff --git a/chat/public/favicon.svg b/chat/public/favicon.svg deleted file mode 100644 index 365e132..0000000 --- a/chat/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/chat/public/icons/apple-touch-icon.png b/chat/public/icons/apple-touch-icon.png deleted file mode 100644 index fbe56d546c4ae75b7acb5a915d7f72750a8169cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14662 zcmbt*WpLd%0DNcx zfS^zS;J>?qjsXC7769PX2ms(q0|4-xvN}`*{+)m{k&_Y!04n0B*!~?MI7w@}{`3E$ z{KHp73)`h|8>#0Nocv58M~SDnYx(&a{x9LHZC9w50I5b zorRZ=lber&oq>ggkA+2LN$g+z|B+ztXl8BU{eMp&k9qy|FMOGGX^`+CEw**p?L7rd&5czquI zN|(n$G)$*~Z_iTha;3rG_m%Nx6AA2^E+YsW5uXU`Ik7Pd&&E|Q*MPut>d=xrEAolk zXGZGGO?JyEzoYEOUq^yR@E-!$y^wuOEVMrqD;g0X=rn8IM|dZC-r5qh8IH|i;? zk60+EQdPXREZ*)_XhJ>WPN9irPT?r9#`8@=^cNS0Ma)0FFI4@^um(yWYfloiU_E3; z=o)}2F~?CKK-fB`9NHp4a^n}<%`^|ExfD*a`)T;MF`;-4A}U))uU&=M20qd2Z}`Ki zDw10K*!^4dQ%<}C&zIgqwkN*SfeC?iY%SxM*CqkL?yQ7~QN4+VCX-XOa7_0=F`g43 zAqfo!4=R<(D`ut-uERZxx-*?h7z%Tc;);L8WU@T4VpC)*SG3u<- zRnhsH!ode9lm}K0-e`EbE*TDQT+VX1TUQ)n>N>`SIGxiUi}&wUZq%>Y~YYjf@_yl zM?_`}j)ShVw=2tJ8L2J%_L?{Kk2CaWbM|-r&L2MRgc*sFU2S3Ta1yc$13T<{n>o8PXD6C_6Z*=9FlZ9jYZ@R;BC)agsqe2;49U^6!RgJ%{q4Bz z>$X~X*`PAJX}pMM}YBYDMgNyVHevi`0F`k_=n|G?HfOO-I| zZxv}C9s5^W?<4BYj$W#=npW>b((n-=b08IykMpfEDfS2A5KLJa^t;{+O8i-ns|fX- z$s0jwv;MxI?Z-|~g=-!;a{W3;5sC@I^s8e`ybJN<8j(wbJx0>GYumPiog22x?#;} zsmY*hQNx2f0rN0l0=-_94j*-nm*9SG9q#;q@PVKrcmG=YoN=x{Ks~KgK;9@5JDDq@ zlUj~_7-#IXl~2#>PRaRbe&dQt75~9>y3y} zcuZ-TCi?m3I+J=$x}czt(z#$qA|KGKKU}|vp$y$Si%%T4uVQ8IisLp7dRx;o`R*A> z%PF!Zi+KCP+^)88U|mC1zVes)hibx9MFnnmS>R;azA$6C0Gm()89HR@2} z=dq2V=i7`+W5_0~3twbqp0)66cY%^kN*i~ph`5h}#ZoWSp!NH*k=t`}K~qfmO_sFF z9DTv}@}~=73VW)VFMWBwnL&fJ(`dz;uML|g(H>mdb`RDu)k#)`zcI%XIxVZ%7YJU0J$=wah!;*9VM^+OO=ld#=FzC_oWt(N zuVv-VsiThW@d3e*X$!j^&xCegwNs%i#8(^2sulSrgu6Jq>cJ`5O6}E^LEA1DVA0s# z2oWQ~p_;lYX){CkuI?MetOnk^|0ecSna)>It2dQ5;*9((;zdaIxTqHAykAh?r?HHM z-!-ikMegO+Evj3GMHvo{sH)_=G%3lnkk``5r)CXO#~U4r^Vb!!)-F6AE}fz3NttIV zZ6VOG{iGzOTa4hpdMV(73`vZ`gX^4el4T2I8Ap^GT!So$%uk-o zG#;2mSL(}P%ns~8U^q6nlhq0L1mpXUG=tmf5+bZY74ip2Z=^_0`{DXbhsbVV${OkH zvAxjkXZ#*O`RtkF)_=>IxX6G=c*+;f(d{#IB|dYM>@S4=%Q^6T)8fwrPmtO2ZnEtt z`~kd)U)0onk4a$H&+$6By!7R-;FSUCFQBi>F*#$;14+}Oe1&DaS3AcZWLq_?F1J5Q zS?%Nf43U@o6QEQ#QC<};^HU9qZqihztzA@g%M&ij7ixu;6u7BoHTc=Ct$9iJqtLri z6NT#anQ~>fLhcD*AAZx<2nyp)QJC|*Fbyug;}G-G<@5Z?gt5ewic9;!FplZTq*GmP z8pBjf_)PrGoph9@XUBkm+?tQ=r!qR5kJrWyLO%o=C6qUD1z- zC1CRX3CN0Y%kGoeq!CYp&V!LC$!oZ~_&cVIzzdNVYKXn+5tHD`IbvkO7e?wOmHs;g z{KHmRPsc8FwY2!rz{-f=N)yx@;Wx?@oui&I#i)_!jY8vR?!6frB;M+$nZGMq z2@H@Euy#Z#)ey6W{7;SuYb?O>_&*6{l;th)kDR0Lz7fMMW&RMb~Odauz3NBmWdNgHE?I-4tfwm(RZ$Nz_8@Q6!vbaaI zZx;{%rtC2dV_dicUkv`tJ~St7T{+%O@rd2IlggItabECALxR}jO(mE-bg7k1QG>kY z>2M*3d>cB<(K+vZv2?Cc$Ff44CLPIrFP8U?$0)C5O+D^{o`jQkIb*zT;n|PY_(>qkYo~SG0uGT;jNN0*F)SgsnToqIM!|1HBJzQuX2t@8Gpw?+MjDzoN^ zVGUDxa_Ft(=5Dd49{z_Cab?{dGHD`p@G=I#wiaNEvc86}r#>d3IEUQMR#E1M?o<^a8*k*foGuib8c(aqVD?NBtVt_br~(}xe@D$Cp3WicoI z=9?W~(s&U}on3jiB)nT?RQNEMa+??;ds!z9ycIS~vDQO}d*Caumk%`*+H7+|S;MS? z0iA18zXGO1&e#rcoPR#(yg6?1*Wq1CO$A*yA-5;q<%I1QOGtdPq+KmZqhY;^^E0=U zYRwu{j9QVNL4d;Vk3zva6aRLEtex%82yAgaZc)Mj4?ox6P=9Wem~ZuGcAPGs_*ZNU zrS37t-%>mtp!D-DE<4(Zs0Ep6qs_sx!QaUFcGUA6ISk#OMc7XNAXxHc?MEa$$##Y% zkO^_F=$~CXubcEs9^mg7Wb`1|E3vS%kHS&B*V=V9lvEso?7+#P>v%92sDOBn?em|G z(~HN7oNi%GA`5Y58R_hGvsKeUvb%B-2aJvkR6$BYwRTJwwXfd~L~KxJi9IA-lX{9~ zFcD9QIWy4hQOXkg@^kl_KTqJUgV(r7Db`iNA=FwOkjqp}v4*?m?H7$#ML_!~V z+!mqswhpDfgb!fGq?KHq(hj>Nzn0b;#_y7!YX@!XuzN~mQYw!Gj2E5+_ktlNW?RN1 zO;oCmE?a{x_j;=+`~2vEjMoY%EE{rQ?YvX+?M2!sG_?{Zi%XuIW1YxjA!w;qgcVm! z3gXuSSKQ#-HEr`BIFofN5IkIjK}jI__U|S8Jy;E!;?ys|FhQYIP8u9}w5kKPcIoaD zUj^+7ve8fSa?CgK<_IS7bQdZ~2~XusbnoRB>AUH)@1><7^RL~K#>;BXX2h|3P5^&! z?!>O$A~8$7pG6Fb$ZNY41xTs^nlz#eKtWVv|)xDs&%=TB?9r=ND3X>wQFp@?uR+)kLB+Lx=?aHE1Td5db6_3q2F z37B#=oR|q7Uvcb3LEbb)hT#SBGzn$ya~w5Is@K)6To+~5w}eh=f+P8)a4jSDx&60I zb$XuVC&@%IUMu~C%dm{yp-k}%gO!}OQOmEQq)sz9UR{$^zhetq;+X6Uf?QL(q7Dth zcfA1%N8G%5bp-^tfs{mNasaF8AWO=Jja!*36BbIP$dB~Wnl3(X|G!lC6Upg&-}ehe z)UtYWP9?q@FV2e@YIA4>U1C=V#8}r) z!;w!l|4ak&g>s49G8&+75|ib5k@jc5@}+N{NLzU)+WUvFC$oQaT>L%e? zgh~g7L@l>;)EzebQHuCPt=`*k_#~aNc^@HTj)v;1gQBN%J2#R*NMz^7w4Og(W8s;j zalM4w6gl+3W?^4rXx$@E#k^8-W&bVb!PqaczvfrduUi1?sjqJLc5VS2SK&+d03%IyZ-L%6&g0@jkqtC=sZ)#Y$Lo-KAR`O&Jr6)>!mPKe7d7$x4GBva3fE2fGX zHc=Y?aK_O+{*smU%9WASXjShYvF{`|@h=R4c@I6&F3ilwfR4Wqvh@#JT+y98Weqv2 zCyDWePhMT&nT>IM95HCZ12)b<*w~SHa!bP$bXp0WC9HDN@jdN-K~s;4kwP_rwe9o2O8W8NJ#Ok@r4qH;fi{=IX z5(enzb?YV3(8V7!5)g?C-)kzv25TGg)sAT>AAXmARK@BNHZJPsN@^}flZD`(RTt_M@|le^AxyggtG)|=`td|iWkOyu{P zornAjRTJ1)#2vmmL1gQ9ia*}cq$sDZI$B%c2{odzdqy2z?mJfe;)_5i8BT zVtIF`$Ak`Koms{7Cfs^^p!}>V%Odx%=LJ z&gK%kon(CJg%pGCt>@9;9ZRxE*!ivQ>O$adlOL?)2wMe zNH=kIrsa7~Ne|WTa`d|%kk`U!ug>C}3XF`2XR121Tr(|En0>=;Iz~i>%z-FG++z?i z+?;J*yPy3=w>DQM+XM`mXxSS42W4-3ZL1aBa;*OUW6pDe9k>hwLutt^rhYtr_Kmhz zXC4t)q$}|6ym_P?zAcz>i>o0-yJKMXAsrpz4{U6xJk7NppIiU)*H zHF75gj+&5gwY*(3tE!-0F?3ScdJ~o@*tv!f5h;;C=mK_%yRT2LVt+=& zHwg{kwjtq9(cAI%3_~!WYPhE-=V#+KSkH5C*9LfC3l3&(5I8Q7nl+RkfuWZW?-F%! z!3)deo&07s!TVIb&s-}GP$l*TIa^4SkQtwFaf11W69Wnu-Q>pJ*;2Gkx$me&WH2(* zV5+Q{TZ;|oYd3fX^k1Z>-$T|QW;h5;goAxhsk`8{ztP|d%C^HK!h$dEHy34TR=Qx` z(X6!L+{2WY$TYJ6l>wnXa08BT%N)lI!gsA=+v60Ee#$ve7ga#vSyMDn90ITBwckxMn zD|y@qc)=6fJ?O>SX@^R-UU$05PTtg+xn}S6n z|AcJDeu7(JhFMn7#dW)LD^rR{B-Fa*W%MCiGC=Gu#L5=DN**v_iU!QWETHvz8~+|( z!PZ8|*C`XiT>8NKY2Lr*nt!fq6#!wU5%13^o&f^F(gt&3=^dSK;kkPT@L^=4H5VFr zlWB1vE`1Np81H?A<&emyfcbjZ7u|2Xi&WQwn?Vb9?*BQCL;^5p)p~N78-lsQOhbx0^S;=O8#ZJDqajI36_(){{`FXnQp|uhzAo$a)x+~1nMDD{eV^AL|-41 zRdNxApALhiTVM=%X~epZuMrp;^fNQH>)*IQttEd~xz$N%;U|`Oi4bKc0{|FpFCjN;s zo1gax^(Robd|i)46rCTh0K|wBn_wgl#|tPzi-nB@f~|xjyWo8oofSLTJDbCByZR1c zzY!;Phn1n-1%loj;_*V*h?~ZHKM<+kk*7}bfRH7Q?E=NZD`??G>%EIavD!wzveEf4 z7ZCEMl-_{Wge%NY#oL-+J^9K!oC1(#5Zfe0_HAbbjr0H}Tvm=jFNV??2sJD#Sy8TQ z*{jvSCLhG25BTR-Q4x5yEx6(KyaADg;0N-yX>eAv@)OW37p_JXeHNH{^h9LF7#xfd z--D}a+jj_Oj!kbFRLxyP7%z;4$T8xDM;!5Q5@CcCk?#v2he3=OppC+Txk$yo&RPR<#5a;5dl5cy81F_ag{(xNlfgXLjo?Fdxf=bj zo_SUMI|TAZ5KZhJOVB`j26PsKFop6qd|&F#H`F{69B!XCOwp(O!y! zZcvWpu#o)MIU~)3ed*ogz#rZ$f1QpKpv%7nuS9R35kkSViu$3@o1+U8-&shH!At?J zWMU`f&5mqbKp+lKyKw=;VYDU{$9>W$g9hjw7tx<2W^|xpy&TA-f9?N4XiKoOmO@iff+}z z3fvr&0P-6dJ(F^x$TM;%WC3jgdp%9ETwgcdiiOxsbG$3z2s_9E5=brt!3vYCr@RGo z%^B&fTfT9hr=PHrGWPtX=piJyzc&qX1Bn>=x0`*k9Xb`({e}*-g{Sc&cGL7k1Znn4 zp%MivPyJw3D_M)GWOaIpH54I)BPEFjV9%&=f;jJeZ4qnz-7o6-v(MtAaax`tE`9W4 zwHLeb*Dx1?R98a^U$df)6^B2rqZId@;q>Jft7TOBe$!}eh>buNz*;x*I_ik|R$N4n zr;^B3)1;&*PC$5#fCf%!$UkslzH18UPCx*<+K zdLP~M3JP@(eiZs;SjoA&6xct{+MVgg>Q9SW0$>m*#+X4p&uBlFFVJWQVx}6~+5*@| zlqNHqdjBU#amGa3Bs$3eLV+(C7}R7-ZVe$mY2MHhDuA%BpO+1gDGj2-(o=$9544of z*+e$)SEu^}85scBT}1D~EKno&kTpSIh7IAVEYx)zzpxfY6&1_3!{H#w&?=C3lXv*fQG z%6sLQ#Or(AtsKarjuxCTs_e`CR6(Hwvj>6x&0)IlgK(fTZwq1;D$fKZSqMf2W&sZr zdwjfRFWgy_cTkC*jaUp}l?FdW8l}C;hO+Og`;qhEW%46A)|=(VedH8QU(&ubZ{3r) zl?8$mJ?Q3}@qx5fmoPR7Ldqj(+&W6XJUP2AiAE827Jf(z9#0HJi(8^@u)>>5-{URB z%X{~A8v&y(ip1AsL&P{bkQXh!H90JeBs4-M zcLdF053$@#HT{JPUjU>Kv_FoxV(?*Rz6DNII~Hzf?;K@_4k`$SvK zFs7?iy)TlFElD$m(Y{C<;?l73rgz;TgVr@km50b8c*c!qzyVGWbwZ|UmGP20waHm0A=X6S6VBTq`S7vFm4?{4JiLX-J)@Q@P1PEiW;U{|Zk`a7 zXl;lJiVKQz#>h)Q3sy?TB+=krt&v!G9Lp<1iSg1J)9)e8;@fC~#`&L!P2-Po3SoIc zYcYApz_dVQMmN#bu9-WEP%82Wh=`ECGzDZ?2ygS)jR)+XDR%Krl}!pF3=OidNISA>7BA|JbVYS7w-wccsCVq~`VVSk-iLgIXL^W~mX2S;l zt~N3~srbi-UKcfHx&MbqnlCSQbx4UP#RK*+JLqL{g5B8R-1O{jx;{Hq z(_i*?lT%z3tHDnRSeN4Kupt+J9@0y?8UY3|*@_W7AU(A^Umy!Jg+&Qi0MiYI@>TRN zUzr!C<|h>HVefAX`suL4X7pTGSr}=$$Z3#f@ZU6|H*vW012VnWy~P(Ly4s>!yesit$Ndz6eU)Uf zu}U;1$P34W-an}CiRZixc$(g&j|!)}W(WMx@xac493-&Cjv_no^!}K*#bVFaGjn8S zoMlSESt%>oDcqBg=N7|rCP(BLt7U^0vHc`yAdbwmfUKeJ-2_oJ=Ak|}ftCaKi*=9d zW5tj%$OHcwIFUcd^c}<%fuR-Sc^zaN7Z45T3uGWa)Pe|JX_Eu?t1S55yyUcNn7$Qm zj!>z2%-q_j!VF(F*-qph>C#zmcp!1i>~jHe0fZOo-KXgLFuE{NQ-A(5rPUYcYo5pt zatDZOig??0e10HNfm0>I8C7}I(-#zP>uCS-de0rbw+GIud0MR5aVul(bsl3&%zIk| z6(SXQQmA~|9j4r5WPq((p^e*h@d8 z%>ZULAzZIEHH1}hWVs3W)E}T=ysW5jLz za_++560x3wrM=oE!3Y zAxji?aufXAu;lxCYj_9qbfaSW$R=&LsNVB#QLcn46eA z(TG1`WUXMv==5Nc{+qWgo0bNNy5`F&RWFnXZ!F$=uTA1Du6h$Ysp7k zfB!faWB179jM0oNUCvzRJvr7ih*$A{?=WE->n^Nw!Pd{5>c`a}K+E{YHUdoXITfH1&Zb1iWB>eB1dS7Z|1EP7 z9_-HkV0?AV-n3F}rGPchH9>~R@ zc9F>PTQ_CbFcgnBVhXM6!c0BXS4-piuD1O54%UVoV!+)={RHL~Fl1jmO z&VuONit)LyJ~4ebM=n2)Ktp$Y229sVbW|o-)~J77pio`!W@OjupBOOgO@yN0)uP00 ztZ&eUz6oZoxpUXo-H^LLewtQ)kRjL1#5#kDKV${)-&Fm7Kgr7|Dy=zpJpAHp8e~XK1h6(R6uYPG_O>$Gt zXzcw2zm=Az28iY2L6VI`-}k2(cyl8$s*=V4>#grD5;Uy+Fzygn)rC;*1%Qm${Y+{7b zzICFAV6NzV@7G;Ya??##avEKAF*wjK_)>97m^v;cq6vNkWWaztgkAA8Ny=Jt$t_D` zu_BJV+idbO|DX}k`4HcIt#Lq{gWy}HNMF8G_MyuHTU{^D6Amk{2dJI=$d+>!@BXcM zL;m2HJ9J+hkxZlSq`^#s>6P`f42+m8Hi^iNpyUU+R%kQ+G=#~mDZV%kI^`WKeU`^Aj1hqi&{urUdPz^k1pZe_n z3cSB^^Lz(2$MoLCyI8!v{&^n^|EkNqTX znSEQ}yZU|9jc0eYtFAbth#t1@;Xmi4d)gc}=-7Cj(}l3eK`t}Q@)3?4AfkG(h^+No z%wxU^51Y(|@tDm-7B|Tq4rQ)aL!9)tnI!6fom+Oz6X4OlF~OM)wKqO9ycadM&SSsJ zT*O2y@9rF^J?B+kqzu=F*fVqGR9%CS<1>d04V-<9)>D{KRSRQ=rGk2lmY6>Lp*tGJ<{eFkf^!$Dhpw%RmvF0SwnSa58K0re? zNflN-CN`GAFskc8RCtJt=Pq1X{w?KP{}#+DMCZ8)M2mpWU=Hn*=K)6baG`H zS!m>f)^+zw>5LgWf=QeHNL(?WCv8mUUg)2&rhUkO-e>h?$AoiC5>+bZ z=IY^pbwO4eetQnUiS#=K0qPHnut1@M14DE@f+7A-m+SdA!GVjj9?VpU4!n=v+1*_| zY5a~O(==atWz}})-OSfc1$#J7;#{f&VBUhO&N~F_Z&Oo=e}b&;37vI3PJ5bQ(#R4U z(aq(#G=KQZBC-0)RVyN1LW+5vQr*j8hZv5CM*X19Nnpx90qScMCt0YfZgAm#;ELv& z%F&g~)|$v(&KgL=;n?aGapVedN*9b2G}bb4Jo!Mnt^>z(-4{l_6qE&hN!E)4@s*0# z#LhhG&Fs?hi7+rIoF?>s-K@G~Pva8Ty2hYvZ1JL~QkPwyaUu^~n4Q+g^1a`R7qe?u ze%GKe^zXC?f%f0{5uLNWiVD`f2CEuR9(pvWaglT=>K89vh1ge(m~?*Q{v$IyY7ca! z7&R{?l78f2pY#lWQco0LwHltkZulL{t1#1%zwR(MMuF1nLIWub@r;+pKbuyfl#hQ{ z2t*V5q7nF|XuZb`jEnN%t({!IZ?`*%bfxLPvX@G!YE=eWnT--GfC<;@bUYU&beTU| zMeTma-by5YLEzqK6YnPCr~7c|ui$A=O$$}; zJ#0kl)A>GWGS`9;+LxqEZu05_&suUa!2R~bK8j#?{T)dKeLKh&nT3rl25bEATH$y* zSwmmW)ty4*Q(+;zQ;o8=WbnbUz+%Gp_G{UajeItyc_DP6rb??ppwvDC;@LSeb4LsW zLr>mxfh?=rsx&G?Z9#dygdS%4MN0@9dRo^iQ&;^q-xHVT+*&8unpII7W~};_P!GeW zA5EUekA>n_GxKf1gUpazv#FpTY0IrdwjIKfYKPndS@R&*0-O1|FE~~L_Y6`kOLOb) zD=*D9*<8g$-if`!aIFQ={zsn1e1X&g73fgRj%B1!B#?CDaoyktzw6Q%g8r>n?Rt*i zW?@*dV0Xe<=XXjiFATO)_|{Tsvz3mdHNq1I&!gy;PpBmwpHAqg=0oZ)_C2!>pGiwr zuJl~WryrEYwby$NzS1}I;GT)eT;$_Q?_XBzDfNAYPpF{=rPHFhPA0d^dO3}Nb#ldpqHdm79!tznnZkH ztl?__mWT~{B6phceVuW`2ZF}{977B0EMFsa*$98EvVghKZJQVbx{CS`ZS_OoMk z`U=~9zl48%JZ^zCIYl~-?FVh{#SYqg|I}(E4kYVYY;j@OUadnsj^Wv(Gl?NUCz2KT zm3BC@%JIT(RUw?%aYHOHt{>M``xCFWw^ z9gc(2mF>9Qs4CtB1Y!gW>ja=1O>6o#gVidNEfp%cFhmX1Ly#8RG?G$P?M8oKmmMkz z?fz1o~^7B+py=Zo@y{Pe1MUz^Jr*b`;qBIiAyg0 z!G<@0#_}~yp5Qb7Dny?3{Q~7O?All9)DxlOZ8t8^&=m9_3T>H$EPi0AXJm#?FQa1T zDDkLr1wU(E{A2O;>8R3>`)uI7U&dM>Y@%abnE{n1(I0K<4)!l2#XcW=&jo*IR}}Uu zWul#a)CyCHh}=(w(`#w>z;m0ZnFUr=ylHeiZ+Mlji31!PZ&>_=)hO((;3cl;8ua70nL+AB+)H#A#Io&F3ssb^n&IdJHaec47LLbRiR2?O56)DaI z6>+^^l`jPo7qENqGknm}b6T~orhekoM7`~GAZ9HV@d`97a3yiB`D84%)_r+5Pnn}e z90;su_erV}jsecDNg5mcOko?5?T_Fv_*v{joAWdnT3Dz}pdE}sN_*BnX`(#t_iGXl z6WMH>@-r^fFRq}r_WM>hy}lp&h#en(>eUxL1&JMBxY|!~0kGx1e*WO3>8u&_en{^y z`B%)$92chZCTvq8QXj7KkGx!UNAL| z(3`h^2wL+hPA93C=8hb#K37fZ5AB>&kGz4aVreseiIvGe=SbvWED~F#XUwY#y@1s}z zR5N?d!d`qdQ`t0HS{T?bI1Os6^*|}*6q3=&3p>bvuKJN9qHZYDr6QmGkw%|D#wBm$ z^#K|1lNa44NE)LIEeN>FEakCpeZeBKY|t@Eq#a2<0xq`tknvjZe@eD{{$fLH1U*Om z#$+c+{mhhVbMx%t?j`hy->Oz^Ad;bp^kD16?;z=ZM{X4ue*bVZbG}-1{5e$8Bdwen zWM~i*q6>f9xL{sAfBI4j-9@12ySc`W*zk0PO$xCu< zAvj(7ANxi)Pql?ryMh4~uR%>25v!fD7*K@BkM&@hz7P5=vxkZ$sKjgPkQT(Iu8~;M1& diff --git a/chat/public/icons/icon-192.png b/chat/public/icons/icon-192.png deleted file mode 100644 index 198f474c80e810dcc3ba5f44687b5c0cd0057dc2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14532 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

;?+~PN@>Sn6?Z$9}!otF~ z6(?LPjyJ|pi{ZYZ&0L?!Fx=VvSm#NOGzur3L#Oob)bPm?|Mn$E#j9Ig7uQ z`Hg$~*kWtb%qS8K+qtKAsK@=K*B=zTY4OgZz#BaBGme%A@&m^>c%e_QU18s%o(J{I z_&47eAOawBW`UIik$q*BeJC*EUi3rKAssj%C8Iogtvxjn?nE`CrP7M5+1SKa-dv^w zlIC=2+ACJI(s;)bg!~G7(5VaL^61N;h8OAW(K{EF}krZWT#lX?(~>FZLus<&+{vE8fBj%nO%wJER7@#_&R*9{EQwsV#)9 z5pK{7i8kIpggJsjoEeNl{u!dd=)P#&PGd8u0)`(`xO{0{xXn;iX!K}ouy}pa<89PR zS$fQ$wh&xpXDqgzpYtyh=;#5KSsuk=kP-*4^m1HEkG&*+Jb%n(}-%+<6{NE)bt(oye6ha5pQFhd6 z%6CR4uaczpA}6m#(4M1~a?#v<#^10Edl8^^w3&QI$iYDMYjVD_xk9jO4#L_Lr3PVnzZf{yE(35%yVJR~v!w@7?s< z_}N{cEoT%V#rL6eqF$RSC?g?y7#Pi9M0y6WBS0Oad+MaB!BN5)-xCD`V6F=7YZ#P!cMJlRFx(}I3A%oKjr$S$Bxvo;=v{DVi5LJjD6XLgh5m4U7ku zlXY+TrNWNST2bVQiS=*InDdQ8{Bry()?{~jr~c!@&H*g;VH2HFOxMEuSoH|Fig5d9AO-65u6We|Pjv^+ zRWip+M|hkj>q2(5=ybMnWtQdCi`}9*>wj}2Uj64Q{ap(%+ty14v+{&vbXA5ky$e+4{9oxCWRp2vO+PXprL5ytj+?(=E zE+WHa|CImg&BweRVB?Lty<=mXL1a4qv3hmj>pa&P+?4p?g{|ks;#`38uKGB;Xqe3P zQC#v?HxocHLLhw>D1T-ca-A>T-8K7xN$ALy7Z}d7^)>62xQ?demjPmrRzrg6D>_(v zv4bny=&MTTdaP1yI<6H#HZm=q2){xCglI!m zbzc01T6rX&Oct-0WYDvPVaJQx#GBV21#>;ATgaQk#*%wx8XG!2PU{2u_Xsucj|%uc z;pu^=$saer)R}Kk(-2pT9 zGvr!qg7#{D>d1ALMkwd@Vk`#&_R3-37-+p1RIb3IFMO%5T4_9d$u6uKHUWRm6Fit< zEcHxir2oZ&`*IHT!qxNumoQD9UvC&_9)*1eSnU7&u9@qC-cS6<10~=`@Nefa!Q^B=gQ^5`? zHistLHeZs?iwnGsEB%3MRfis5w&x_Lg~Brk)LSL@Cb1}pSiHa(QKOtjb#JbN&gi~0 z(aH8%NPzm@Mhlqu?S;@M54ZrQ=6Yp4&5{*q?SZ{1zLRlJ70N_9%iV{x#(Hm;L)CI(04@swX$)Em4(A zmXh;_^`WJGN>h8uFU6lgJ5C}kLfh831_4zNa`w{bt_znpUwet>Fw8}6lgjhif-YjF zQL)u<9ch&9!@@fMo%37>?fxr3kGHRN4lb9sJ{PT4*z(!d{3*(?TzJ5Hl)mxK;&(3n zmp-;zqC`oo0h;{OXrT$$pR1t-zX1b1;ykHO3})K|CLv01ZIY1|iAH}d*zTCVDw;Wb zfITeSSLJ#2DULv878nC{zg=J8P;}?y0%|kla=rh>b0QFX2hbs+f^g1Yv}|PJ&Z4Wd z1oa;4Q=I&wHg+saHeN({-TI!aYV3YqG@K-1$nM_vr z#!8#$pb*_xi_#}veT;DjdFs~cihM+IWFXbS&pNX$#dRA|FK#3EVjgcfeCF#`ODG*t zRc>X-neLGqSX_1kTg8syG&harpXfP&R%p>;3hl+ zW3+%uzKB<>D4~wLuv|cGWQGpiUk~>KMKAYW*c8m_eE;)TzD`iGxQ4e>fqZ{#*qy>Tz(+^c(AX?Ab#4;VLiQ#NG@FWd^kzM0YFutjVA zq5Q<6zQOZ-*R8Y$Kzlrd>1?jKzCM9YxDKERfFAW*nu}m-nCujIU6g)nACasPur+)Z zq96B7I4gHOse8Dvpjkh}ILp`M7s zdV@#aBti|5Z?cbN#2?#%Z3g*+A}Cl6^zum|oBrtd`7HDKA4UibJm7tx#F%8tsw2_NuU7kkuvaswiFg>Ko}Fyw2`9JF1=ebo+bx?W(|#708^Oo8+`?h z+6^5xGu2dJkc}VMAAiE>-sF^k@L2W4RHTYTn2NMlGtPs#3JYb`ThYbc>i5do`Qy^x zrfqXDdnkyHKb4A@UsReT@B7OxmjicVK2T=;KRgkmUgsd5nhEK7TF!8_aRC)s_sCCm zboh59ziT{k8_ymOU{GOJiq5+7!31E+Fc`fc_=w}#kI7`Q-h-=2GoVg;6%Sv19^x%r zL$CK;%f@dbyZbq!CT(5FQ7W)Hv|_v$PC!jl1_C>0rUssEdjx^{hcBBdbP*oMcr_`+ z3=mC*_0|WscB+fX_ti9G_=``|_E8_K+9Ng+*K_^&IB?6l5GgO?S@@drFk!5?gDHmO zuM=0pG~fDOm~Oc4kSppL?OIWjpakF{q=}j|DoQEtwxYOOcV&!b30G&B_l_xuMStwg zB=7`2u(a^n5C2qd9|zZ^MJXwNJNWG_m2P2*%&<*(_{BaFA6bOkL4p1^pZ)E*P9f@i z9Z#-%G|5C+|N0pw_NwsB!HgH*;3SrvqXZXswo}roCSFsS-k^%G-Gu2BWIaOP&I}Lq zQby?gIe(aaE5My))V$9|Ar%J%h588TdeMkuHFEyBO0xgh3~0taa0Cr2B<$z79F?K; z=fIS)n4yHefTh@!rFHQl!Mq|q?GoeTI~cn~R1)HC(XwP*jB|Nb_kMU4B8sZWkny+G1i zqG|XkW@JV^QJGvqkn=0pUB=HwP`p4(5QS7Uz*-h-*XfvymQTz;Z5xiCIQXZV37L~m>dC{yRds7(N@2ctQ~Nb$qAKN z?${q|Whi~l#ihu;-niGNdDEXUbx8=%vFaw<@A)on@2#ud%Ac1cd)LebUBGs4s&j3f zLa2>kXlio8`MW|m;f<;o2|?_ow|R==OrEq+`+e#?^o?g!*y z6j_dL91(AjGCU`8b9$k?aodqq*Tw#{@miipJg4lO1X!$!KlNWsD#ib?JA#yTK%%=O z?({iDdrDVH^~n{g)B!08O0lxz=U&KhvBw~179S^VW_w^^$Y{AqY2A^JKNP22NcWRS z?K?`Htu=$GXoZ&! zn6h=x)R;bPbf$GESx1VoR!&$qy!2G7@??4Y4v1*7g6ooYok zj%fphJbVyTk=l~%1!7t|Os(>(Q<*zrj?-9@mvF}2`2WoTYwBI(-4e}47=JdJBGFhT5oKe2cX5a$dvOG=WvU*zY zl(+*KCzJu>t&q+u!}|-&5&|fGUpY^lr^>seVz&aU{HPO>xc&sYXR%)E#qXXonU7BR z2v0~&V;lx@8hg0b^T2eE4l*+W!+|MBxt#S}zl%D?nHG>nI%|Tzwc}QjX_4Ror%Ngg zgkDLin<^T{I$)Y*go<=orCV+qhwhWAayL*{b_TpcI(ZbJGW#b5{+o_W{)1 z#B^@s<39!9C4eMdjorY}=9It?ZrbA~-G~Z|Q|)3D-c$qVal3%*9eWsbFqBEI=>uH} zGPKGj4$aOK$p#Ikux+E6KZM?4uN+rjsj(<$W{I{0Ha%5-sQc!^cE68y8HT*`teu3< z=3`WHK{hUu3v%12pqzZB=QB+DsLf#MlpNe@+xY)2HQd1F|?r@;DBHb*zV~5 z?lzveOf=Y)z4?uCn&8n-|IQT-0k3cWEQbE4-Mbi6qwG*WgWL`W@&e)|GWGc(Y@j$p zF#ZFqLM;3`^gE@yZN0@__6@C6AQEAIg%=%e&4~V}h$eDwawN|bcWPomw_qk4Ck8v_ zPr5^NvmuQ)9J*bTTRW9p!-JZV3wscOf3P0X)JyS!*k?C+L3J4zGV}3W% zIuQTP9mA{u`pOmg5-Dtha_n*J`I8p8H1@^zmKUFc>f&}0Qcmhbedk5ruuhnU4y?g! zTz8;03B{CUq^CgV6MhvCk3nl-Y7({FnJ2)4z44wUQSmY&U6(*Vkgc6FY>ImSp2OyV zxGWpuS)g;Fn@}-o8(H|}u;}URMT>&o5xf@axwld&2ybzLz3=^#LlcYNwz)yua$vw;XPHPrgh;#T;$m|Otk*7@Y9dA#JXUQ??|}kob!?u&cF_Hq z^#?jc!!7wh?i;s@;!rZAlw@)5)Ac4)2a(e4ZfAt%74)aSB>jNjp-Db z>q#IPN_X5fLyYqtmR9z#l;q*o3(31$$d>?R7>ue^w1ZH_zIyP6jz}3|Qj0pxWZ@5Q zh9C|GGsbTKBQddoK(tBE4~xp>y6rr%_I7)99vu6Nbj-LRwrm;u*oHiIDSsp%7^e^N zU7;UgT}2@Q6?6Ch?tnELre^bhb~ps$j|}7Y9;Lgq$S-yz6a}el30xZ4Zsm#8cKx4$ z^v>ofY~fbg*F#T72JVO1Q6wV-9h^qnNEQsEap9un^Y^Fe8M@bOqoJgqRyK8%dZFH( zITZn`MXM4_7LQ$5(sB^Zpbnnn;!&(NW={hjyd@q)A^d*gU8iJ1hRe}MJC@uM7%f-A z^f8~ZYN1IxqhDj+ruq+&e?!z+Rz0`g@RVt|n;W>p;8#be)zoquFD_?{pOd}DRYaR6 zogMv)6)QDO5r#gvjB&{mU2jrZGPdG`W%4aLfJpfsH_?64zvq0 zCjEi@SycMMRO*HPRX(iVPV0<+SmTbEc;#l$L{7%xDh_7ovzBp>cq#N4PJT0j4)unN z+Lj)uRtXNJuZ?2P3C+E&ClDU8#tqdo9H`?*{LQRTbwSqH@?M#@9jfEoEv#3nvctmupEOVcAVrgypmhMj#l8Ng-1RNi)XQx+ZsOtu zgVc6#LqlflGzB{b+NwX059!K~Sv!DfI^S;f_itsA?^Xah0K*2 zCq^JlY}`;CnK4q;0+{{=9>W)sx!^ea@$QVTI8f<}&=w%q-KlkL>zkM>oL}YrXU5^f zs&i5}E&zdX>DRVI>!|r^uNU!p`cyK~AhTPvr>SOlqxVGY;pCHO3U6-jp)~L8nOV>1 zd$=)&1Vcy{iiQQ0>YWktvTBiiQ+I8+`ME{<#4<~0U`xo=r5jjt-swLuhE#}G_0>Rw z?K}1?Q(7!b_QfUCZR8w;Yg!vMW=CvWo%tGumhLj&+bV-hZ{1ld5}AHt`M+mU`}d>| z`J{?^8E3s2Rqh2RJQcmjRjxAk)G`Or6=#+I&IsK`q{@#9=P_nJa8s6pTnwboE`#mI zO=}qw{3wAxy=L9E3{A>nOtW|)ONN=qzKZd22KMZA3eqR>3|b*Y^PxIw5D|8h0Pb3F z&_8|Dcg!M-;(hio=SU6!vD6xRjC4U~$c6~W3*3R`Ps>8|@N^^m-knmny`kNYu5+t= z6l=zPkYy>%KTJEy+(9KCTE7?t9a5#C?P@IfM~#=Op@`+#0;m#hOs;~~W}=qNNl_p0 zuHZX*$4EJ&07wVJ62RM^6UetN%%IYv=jz5QN41X!v1dz71<}AMbt7GG;~Ub)!EVfa zOR2d{Gc)*vs-EYKwMlsw7`Elj;BM~3(K-=T%fBTAS8NDZ49J6ZHGS0^=xtTlZ6}fq z3`*i!v67Y+Mx}Mqk z?N4-F8izEIHYbb&#-CbD1c@zY%4@m`!DZ8Dkt^b;#i;WTATr!rBD5~DoF7T1k!Jj| zClV3|Xf0Spwte8v9%8;ECeQj#1l&}~*<$WO3h5!HwJe(Tsf4sR*^eE(#HI&2m**L4 z3l4o_eKN}MZ#Y5e<1W7#fE9#yc`(NY$fV!>s6QY0z&;hB3ho60vr1k5SzMn7(E^*s zhl6?{*vrOdm{Un{nozVxr$NgYlj=vfBfb=w$-Q~seQb;5xz!)gHqNx?Rv#-z4Mfc) z1L^4#sMH!*s)~NGuJ-Plafz3ch?cjCyh1g%fsgCpm=i^4pOVWv+>5aNqJ^Be__@3R zj!FaO_n*76w}eHwCnYm58H8l{I*_Nei7HTh<$37t(XMW*II-<-`YKF0N(*U}B%a7+ z2qEiSQP$qFr)n0vr8Tr$AoRxd{+)LcT9)(;4@|G>=2GJK1xpCt(7RB6b?xv^q{BhV z(j!(XJcBm$1AQiatvVc3A7de4f%_DJC=G|gp{i(~M51eqfW6BFcZmJ(xKhQxn=0ofw*su zsM7@-d9;>TDNRu0YtXan!9HN^b{#L~EWeC~f9lkBN+Nwq!2M6Tx|d|;@)!(cb=TMg z|E8u!D&UFLCIVl|#a)t+3C@a|twTHJrq$U_Zo}fW)R_NwKIr%9g0FT?S7JaZ^DDHS zWp5=xSzQAJ%&PjQ(TjjTNwD)SA6N6Q`e`DsEsG!k*Es$v0p=czqZjrRN9NNBH_i^$ z!lgsOztdXWt_&QdnJ+o;%l!#V2d`fc2!uaqpG=zy1|RjWQL|&n_Y?@vEg{LZ+HY?1 zRE@||^!dpaA@Ts8eu3zC;1%E-{-YKYOjPbuJ(JiN+gf=u$~C_KJh* zI}e$o2LM|mI-@mB9Xl~Gu;-x zw9YNweUd*P?+LgXC+C#m}u$b?j zf^%!B+5KpK0jcO*~3~5BI^6ma@r(5NYB>o1ohD3i_!ZoyGym?2~G_*smjDYBJ zlEzL4IT9skT10VphiCWG?8^_X0l0N~uU=-W7%?-$+zcNWMP!x12VRGk>|?KCDw831cjs$7Fso`Ape(H91o7+iI*$eW-SVI+F5Q%4f?1{9g`UX4|}w1?3{s^a zIDf`Ckj4uq6<+J@4pVeF%1wGI&C%utKazSPm>tdY3jNeQrEocv{JZI5`Mr&fT2T>D zzT8OjwCDIe@v{!0z`T}xH-%&|jWb(bO+WCO%pbF315rRPUG4pl8|qDAD`S;N$(W`o z-Ym1WjU)LN{h{9a+}{n6t<9jvo;=3OzzWVvb&tB+xn|yMa>22U-3N3 zhyo6|<|?68xBhs=huiDNn5WLX&`P``spv5opEwp(5&FoizWpq@rur|X?h%c%i>dnu z|3OI?zV<*xfRmb}mLS1CwlWU6Y~h`zRS$IQ2S9pthz-V^8eQ65MmZGjMHRLxlcd)w zAeu(4vNuiEmVbnYTRFGuM{79Zxh(fwhZ`3I)Ii$K8lk0uM#5;pV=YIV{RNo2lNz_4 zC+ZOEu-XHS=Y2ZrCGugZ%V3vHB8DhY{Ypl9V$UbyCka#m=kHcyghbb%%!7l+@i_n1 z5EH0>wV)VBmZRsuWl9%@h~T^8WV)z9*Av38Sz%k`&wc(7+<)_=D-EO^?~WV02v=V) zuDXG% zzFR7wDPSpzMfkr>l`uBY>C<7M;PvYUdfM;~&YCl$o~S|%brq_#cR;mzN5+y|hiK3y z@>m*ISQc|jln{9&>`^Vc3?oCyN*N==+H$gdYIbGIyJRD|!s@#|-~Lo-IzC(<@*z=t zRQ3IY_OW;t+g}h3X+%&b{tpGyi#YyZKgbh!pnuTi1jTPKvpuRq`rqj2&(&{D@FKRU z=ULE+HZ~if`kH`^#;H_?s&kUIJ)9ngLEDBkuUp%2rzU1UfQYIp^^#?;O>X^P&Z$cliGP)88?UC z!6zh==kCrDTCRl>!XA1g?p%!^82#XG!L<-Hs%upEUG!bN0OjhSNTpRC?X5f|D9|6f z5Am-!aDHOqlDp-uR87q^f3%#QSn}7#w)#Ge5JK%+Ms!m|r8uwFduongWOo-B@29@Y z>gK2hk&?0?PL*(9)f%Cix6s$UsLn>@wp|NVtn6LO!@uPYL$_N4?)X<{4PHLNeg}Um zZJo>rYOp6-&t7D!R@by+(ivp`XLTWpbvu5r7_zoX!bSe(9Yrh3)z9Xi(veqw$L0H!P}2wWB|hjJ;L1q0PXa#n&Z6g7QgE=0+^l;Ch{R% ziJe|pSOJFvWof;(DS!9AAu4rr^Rf&dsjjN;mrrEN%F_r&P1n$?*$NN7ZVA(VZHkHv zX=TCm`a`LbbI1}O)Urmj$|tzoIA?b8Z>2Ss?Mu|{FJy#^dW1Se(hnm$ z?UFjrasOyGe=4>Jhg^aOmX$zUB<@1X#ZK4(vmv|$U_H= zm35P07K%e$a$<>UMch*{a6Q^~Fz@N&e+R5n{3|a8Muw`h(+lDG>}?BuDE)EWm>!XG zv)O_uoZO#IdCxJwrPV$@tNzTok1rz<{0^FB02N!sL%RwAYrld0by9w&I$Z6%dx=B1 zpPuQ~5#Q+64nBq~W65yut3k1_2ln*2S=k)R8G09LQ@ykO8B@$mJ?NhzsOAw;QR0YL znSUNVp89^t1^@9&BGX5HSf=6wV9gI^BSm@+O?u8zbuMwn#k(>7J9*qskvg?(fIm~# zm%pm!s6;H6Nyi<|`WZ4|vK}vi`uiD-p@(;PIJ`)w-zBByf7xka!U71A?P?w}EZt0a zefDX);4`3M6YAEpP?{F68$`PIu#y_@lAM_94H_p^y}~AD)qA!w1(qWG?1D|%u=6%5 zD^GgSb!$is64e@9QD*kk=gQw~TRlOxgsfWYrtlFub#*|k_g{O@ji#@mC6m+drN#UE zNe_FgtT$wQP`fy!ADFwIWFPzz$5c`dX>wUip`Qs*1uZP<2wS96#F|`&UVYPj3W@y4 zs>s|bWE7xIRa-W;aE5)$kz>qDZNc_U-c7$_;R(-5sp!n4j57lTq0g$xq!UpYg8DVP zDE((rj~DwKb^h(KXZPVoTEy>O9oV8mKv0JcA=>_`?VWFsgMvMihOHg-pj9Ru2 zArenC|7neDl?nYu`)j4JKHM<$8RE%rxE&aB`r<>^wD`jZ2J^J(sx!Bl@2rlLDGBdH^qT}7w7f8OlA&NjbeOd&fa2pcKBQ40ez z{N-7C`rhofvITNB!G}1^VSzY2h=(vU2kGczpFcC2w;31sSh{8s&Na#It5tp;i&*|u z0OpGgOW#eOQwFlHSKL1-K3epyWk0W3w6?&r=dfCoaR>8O4rXYvW_2u@Ryb-&*A7C4 z#2=aR$&0R8pK&E$HP$74+mnEg3B=)72{ePYa!z}T?oI1Zs- zfkyp5)yYT+!$IDxM?S=CqBr2B6@6_rkGd(R@5ggHc$7h6AZcHj1QlM%!G&C|-9~MZ z7s~m{ugZ>~KE07X#m?z(!9BWmt7-AxiDsAA6=9D+FxpxwxIzhPuvrKvzQ$c;cTnfN z4x7m52o&6fzp+tD{LRUkpP&`$zbq=)1!1ET9ulB^*{1Nstp~`No$yHH<5fH7r&&JpC*TTA^#6eR~Z&Z(`*-a zcZWa-uEE{i-Q6v?yGw8g?(XiIV8LC(La@N%?r=AGzdL{Dd1ku1q|d49s*Zu9)eMZT z>?7;8b*O2YF|wdGE#DfE+map;if!Q|*?NOiN*L8piYcz6sx|zPT-@ko-e@S>e{)bx zo51dVGh%ilV(8=1w}x^|Mw~HQbhUoeXYd9tW_x~FBU|00T62@4y-J*{~^N!hfsyrwY z+7G!2#{3NOQQBu#!_YEM(=wSa@e(L~Q0h_4dP`~IVJ3fvuD-tyhFHoDq(hYfum(Dj z(<7^ys0x;W=gcLWN9H+bGCE{Cm2|{MUm3HD_fbFdG0sJK{p9{x@hg&rE^wZyemJm* zM-|LeM8BW^_|E$*;{BU7ZQJx<~OU~-?L)0f`0EKO1fsBO|_F} zL#((G=h>Fb2_|-)p-##e&479O)f$0#nr|VU)wcRsuwE~UV|H3Yb#n&}*REy6&eIB* z=A>MnGE3`_B`>=_G(~e?q0`<8H{M}Z2Gnxg*c5p;7ahE&W6}TPXZb`6-!bjez1my% zc!w3WL%8{E32zU?UGOgu(t|~FsNtC@Wj6t(6w)k+BzYmxYI}S2Lz;o?Q=|&vDQT#8 znW;CsCEDQ}lW>#K49f_H%UkRsOn?A@PpEfQ4S*l%7kmv8W7pIOqy1)oG?<6j`;3^z zE?ptrJ~v05unUvIb^+?4?!~@Pf61&F{rO~ucxQYC6m~hP{i$Iu31;MA8hQx&!ICD{ zSfvVvirgChG8*=>bf<||9^b*Pu32$tVfRuUr_cUFUds=&tzpv}1^RnArUYA${*J~^ zf5UaZXX08CA><67^&H_W8i_-vE@?wt<_36Nnr7!9RE1^9gcpx7DlWXXCjjLlt z=>%Ty$o)DH55Ko9`V1P+K0+J)5`$Vvqr~qI?SN?Gk0lZMrJFy(UNKc2Zzu`4GbZMB(oG|0 zq>FJ^%RD989C%GUM>X>g3C}3FvMRE(_CMVAb3D%R>khzP24T)*Clkh$UqdfPK;IEU zpLL}cY0(Y~shHwGwxSif^EV1aCHLYqy2HiG7c&8aVScM0q~WsEt^)a0{kZ&O^6?Hc zD(DYW#=pN~L?uLQ+pS&^_qaswYSI(+xePk##epRmR?c*NiqqGw)BYP*vb+tZ6K99s zLPvL^+go83}3k(k<{K;o#yw3p&P?8RCs8?DCv ze9V57D#WkV^RXZJt&+g(7-v<|4%PZGLvjo)WJ#Yh(~VVa95o~m0W8O;1pR{zsLA19 zvnF&>9)8)boL$+@e>0GO(`$1ZZ8OJ;%y-W8(XXGr%%gOyHFn^C zg6CVP`?dOM01R9`>pohLQP>>Y^7{+e+!E@ZX@CE-rB2(47qM9X5~Z5wyL0_7UbbcW zTd>a}Y?Zl%iR%g;OXbGMoQqSK!3e!FKnV^e4Qk;pH4xhU8!sx$2}*`_*A%SQ9JFBM3v(_ zzk}73&O0~kx{C)xlA%bxk*@awYT_~_%m@tPO(&ZoRsBp)o3Od`qbxRWN=Nh4b=@^? zxo`fZk1d*|dH;y{{c!yQ63!ZZRI{Ms9nSYj8)+Z09JgS%s^FG;wmx>q8PeElGEBW( z1xcr{QceTOyuhlpFuj9EW+Te&|7LHIC6^X{=u%~T-1`M*gThvia$q0@BKr`IpGf5#;C@b+qjAn~Ms)hB9JI z$FZCT7FTV33&#Yq6fSv*7sfGN1%D4*TEJK1K0D4VBiWT~%fS%2AruA$2<54(9lSC^ z?BEQpiAn^LiSvVOY=9$NATcwSVwz!`nrBLTA(&n>QyWxSQ)us1l3 zazWp$u2~af&bMs=rYlg=uTiJpq`k>foP|C)yoZ(F|4`{|Tq3pZgr17ppS)(~@U=3L z5>oiP$BKTR*U2Eo=S-oP#=LToksG+icgMvYHJM-`_6||5qe}ONr%=%kMpP?7%pKc< zOPogiJ~c%-E;LoMBM$sJ;!H4_)qC?zzfeq(Iluz;8}K81#FeqOjbr?|I+V;MTL!&u zQgA(!PZ^*+^8__}iC;Tz8GM|j48UL?g2f1`=5PO14DM3|dEu&-v>9E`UbpKzAGvbN zeX#yoac@`z42N=!cw19b04`z2%IW>T;rc4a3 zLNiLD!?Ljo6pljupU4U5h^jh2CyNTt_~N~K=|u1R22;E5Rk}svzo9iw3%W4MK`mhE+Cp#yGi&BTe~3-ifYWYAuC~lMlZAlNtY*s`(f* z1Jh0RM4@e%+1wQ+C*`mT&xgJb@mmrnN!rsa^FX)~)vaCJ=0jCVl`mwsb4o9hM^QD7 zf@yg}ZI3-$OG<2FgXg-Pv6Ts2zT97beL-7f;)Ts0z}+{aV#|VrZMW4c5wq z8qI)Ndmjaos5Y1MvXBU>3rv7+jSy1Qr< z7e@EhXR{`MRvWw*rvZ}CRo|$?Mr(g;-P0iEYNjh7JuJZN8~corZlfX9zxX>i06+K3 zr>6rZr}X3{(7PVh1PU8R`$h`d^cKT9w8_-o%FM5wB+qDowl)UQK7^%&0+`05pOEBa zlb9eq?BHB%bGS`WG*3(@XU0-0!Y1x-%oLX`dx{ryKM+St^r8jVi8$`k-N--grPB+U zZV3_fP1UBtbeVL~lbiU*P?KyTq-LlGOon?#-bj{aAl-?tb8q%+W%=Aurt~n2dZ}bp zAEw4FyK3i$t{s18w(LSxn<1$E7O9iwG3_mKkQ2?C11nS?1EO?JXrU#~)eBaB;6E?o zw7yJ&61%OR`z$dM!FCQPONT zvV9x6+7#SI2d`fuW^2Bz+NAtqvw zH?FyAGtIkbXY&?pfuVW0$2V4RLxXs0bQaVO^cPeXSH|96hK6OaoTZ~9kK%PA>G41! zuI5jg2Rt<=_HO%tiU;B}0hHX#U`3&byFW$X^pP)%sX_Q5Ls6S;>tq=LQ`v|v71-NCe%sMz?8{Fb|T}jr&I3G?*Xdm^PJ$`KQ#a_=x8sn?+OS==(qjvmUjX)D~B zzQv?KH-C^VFMAT@P>TXoPL`s@iq8DAPw!n^R(d@P4WYR(9$1KibJB>R5M&Am$jaucUjmw)BsuOsTnjx@ImP0D?;n3O)0 zPtAZ&&mYVq3+Yg$thmIjR1a^ktoy@Ux(jn_m7gTU{X?YhC5lr%zR)A-;1{EFs3Fz_ zmbEzlx9ho>W|a;{PK7}C4nx9}ruly43(bmX%%gHCLmzTd{+wTkjIT&-ia7jWCp)+w zHmq3j0x~Du*xv6e6?+qT!YOOET#a`I$0Z^u<@CZl6~FE%bmmc5#u1RCxkmTcYI0(E zhd2c*8VfQw?KnYSADLdG>wb+7Y}B{$vhDo7g}v>|!VHl;mv7st6tB!Tp}eFgP`n|n z*UcEYnk&PznX%tyk@d~0%xE-3Jb9%6Fc)l-NSN88 zqa6LwP9%^s81`7S@GPUqF3(uYx%72 zEpZ-TyF-sHG-24WwN)~q-R>(7NE!$5Xg3UxW$6I`ala69^y=u;VVY<&*t2<`GqJQ2 z7Q7Q6t|+7`WeWw+#VI`P>)eaKvz%N(7} z6q`FbvUY+?WyiP34>_bLc!O2l0I@L%EG6L55=JAD|6`F`Ut1DI`k8#Q=2xIh-C+tj zaQ0+OGnFbFDO2g;t>w^-g!O$8s=qJ~k9bBCB}j1S$7Jgf)U!l!6OX4p@aD1Fxy1aM zoVem}Ox+EAFuRtppKP`tLf$qju;WQ*M!A9M{PL4RCl&=IQH9(SP&|(Dwjgo_c}tUF z(j<^SZhfb>e;C9*CoxdxcEKHEwW@Dbe)6yTzZK&Ly&GNO~&8tF7e`AOxHX!(}FlIVwM6-V1?+>h)C>?Cg&2-iV2+6Zkk)ZYmdl0V}E*5vhnssBeU;WmYE}WZH z`$*49<04PA~C>=DkeM9ec#MsEjSImY==TAfD z&(14Ayx$*vWhCkSmFC%INEdBSeEX{GM(_yD-VjyD-X9`cnepXSrZE3MWrVI87U=JS za7Z?}7M8@kWNRc2sz8cfWTm;qElPi|-YiS3~@88{PlO<*|(VewoOV&f6&T#9x$-_LTky?LdD#J=Rm%$3ZfFUJHipJUqa|UrIIx%v`l;8!ZwJ*Nf)*uq^{L$T zB&TA#k;*QpGhBW^5WvSl{BfWm`G6-|OFTGeYo*z&R2utS>vqLi4}Yfup39U|nh?Ed z2mTm?=Cq-9P@~i65GUqj*`a_a&?q`$(b$$fo2B_uHek%iL6H8HoL)kUjrEQGbcb1b zxEf<9Gz0h44j9wL&cn|M40@|$p|c0nL5+MnJu(XQpG}5piqom7aQyWwuIv=#UDSgo zguN#)drHBZtTB|#$)>23)aNmvdv<4knlJNb(!i~vBlixc*jmdQ{*=D1ocbAODf5qX zIpU7b=4Gy!@}2%rR%FA5DlUydpTv|835Szxs`|a$znl{#R%-=D!8iw`Z9$qLr$Pc6 z!ZA=+ZgBTnfn_;RS`XxrUQ9S-2GLu0$>y$KGl z456z!fl6r4pJBbtbimmZgXT?ZEd&h>`lQPBB>Sb|5<2p<=phAx=<~C$7WMIMcan>C zvDAHTvce8LGxLxySB#gp|6s?93af=q7V{SFcmt>jS?|ec*oO$*I#AYw%LPcd9jKVW zlcMiT?HW4$f-ewh#UzoviLfJ38QW&!a=~pTXO}@ALzY21Pt70JDZU>S*qyVyh4N5F z+Z$-gMKhs-*HE*A(yrrqSW&nIlprojN8J_ocWRO_&ggE<4R^+Ay!_E- zzsB06n=UT{S^ZS^^;#lnsQ%zKOn5j+MfY-waqlt2i>X2slB%2gvcphwgO{E!B@_Q2 z7z~6!iiHWcSR2RebuUSS=M;lV>WWFed?&m;@Z6We|mZ98iWL&leT5584?uzO?;WkaUUA>uw z5dsmZzz{c?e9@V4Lffh^^`+1X&~7vxDMiIyQz0}1 z6VQE4A2W??LniP4SHG+0M_DGa9)Cb>>uHV2=f=s8>RgzsN@^}-?87&L*b4GMe%ZW1 zd6w42aEA;7{-mFsnSOIDUlV@q>P-0n{ff}ke^>}~^VNToHD-e0A;pJx;p8qhF22S4 zN(+arX9=Mm=!Jak>ki`qcTFP=*YGsZt2Ef9bWGVf3mV4v4W#RJuaMl;!T&X`*a|+gNrzj9NVZ2VNKeQs15P&yyDxK zG&_tiSBi-o9boFmdG;CFD16uxLd-G(_4#zsG_h>)S{!VANw8f-f|04n7u@En8P--4 ziLxi2>*i}HskL)yq2BCx^+kV5!}m5!d4$Pq@{Ld*{HGpxE#wadOsM*h4KJs7M4DU} zoJ5JcjU!3MeX#zR>RYJvXj-0{1~w!NaBC^tD+Dsjy-BHo%mGgZFO!ZXO-z+1bJ8BD zB4x0u<>XcIyYhjbhvMR_r7E2$hP#SZkNXTM zmPtxYrHt9}1!KCO3;B|o1$m>DT+b?GHXuYU)qL;Nk((9l*J5$+(ah^yM($sI%C*K8 zM%)zmD}Q_!>Xji5d=g99ocBHGoi-EBHo`wEb&ZRFb$0N)oZ+P=xp; z)6igW#xG)Z5V?Jz(EQtyo12jZi6_3;NhV0%*Lq*@_XR?)+%UxP&VeJOKY84_V)uA# zhwzlXItc(E7ud0y2wNSx*Eu;sd~-llLzX*Um!DV6G<8<_tc%C9X@S=+g6jHCvC!k8 zzeA>UvVvLSqb$3me04r@M<)CThvj1yozsu8VHSOBh5rak*-9=rHs+E$xYeZoH*p3& z%VcWKo>+^1%X=YU>M8oOorZIiZNpURejB%Z=dnEb?pgU1Dfb2M@c4W(9r(?qvYkUL zHbDcker1X;x0NYN{!dDJ~|y?6c>Dd#+XQcv8`lAFW}sq>0M0@&;o^wU{WP=&5l0X*l3h?~1^G-qO=d<~Ldc;`cV| zN~~BsdBeUB$=xg?QadvZIj&!FAs)^)OE&F(dP&f|C{^686dQo7yGjke*~yWyd_0>b zrDo2rdS0b@36gi?9UT=KJ2(-}J(+!xW9+nSgg6;Sn;(0siV3};aEa+<-&p&DI_ic}4wn7o|9dgK=e zDmPZj7tE%^i@`NHprNPI#uLJsq{yk1#h;d`9cYXM0jPhjR#rz%Jh!XDjclA(O~qF8 zb*^H5mHJ$?-hTG>B?@r)!@4OHnWC!Kr~_gpNPLT@II{YpW`y;4u6B`EFxQDYI*qPp ze0+=U&(Sz(sBX*+FufbN;F&F43j>!tfuFZ~7CHX2I8)EhTxMLo41z@2!l#kdMDVqG z(Wi3YgkAJzb5t)A{?-RYcPiOaxm1vhw(d6;kOKA!x+?>#LE`+9H9Ne6rbOe6DPj;q zIt)2nmd_&}{jrIBdmD8F^T%Dyg`Y%X^^3Jlvb@GqH{&gGMiJRr377e0duNis%y2Uy z*e7gze@xla4fTx<9hI#z)4ovF79CRxOo{(^SYY^aE#MTRO<$e0Kp#y@RbQs2dFaV4 z?hXG$Zty6gL#nu^R;7}1_DE^&v3Yi|`Oq4bj`Zsg50xO~4qnvmY4q^O*u}6upr(Hn zfSUT%+`X&{shfgLsb~CH z+^FWXTS7XZpfrroH-J#x@cnsC1qZViaxPx{_s`TlKIF84qS-*3K-1J;G{&9?9*SoOnjpee! ze*0x`VS7GWX#nRdf>-mTmK4G8Sx{CRU%v7J-?3Itt>x2ULC)w?npz~*5o3yW5KvPM{ldu>cxvw8}Nf148V14>*>W`Zg6U2e1!Dq`8 zP5s*mcMUqAGL|aZ;6G_9#oJIr;T1ukrDNhV2UTb(KRGbSneaXDbcFobjLE-}F6T?8 z1BR06#}S>x82DgUyAf7(2#!1jOFJm20DRP*N~h~26gt_ykc{QgTt`fr)~%=XEJ^SA z#d?{|RuVbSAOE(nOq_0^%wEflS(Ga2A^Nh}Sab^GwR}*(IU;SfbA`vydu*hy$+u(a z{`SrOKVqDC@u$e!T>n>%K>ZS^9MAD?-gHYtL-`786;2D2D`i@s37f6Z2 z-7$#BJ0Qhh`a{rVrW4;?a&* z_BM}y<4NwE50tp%hnjAEH5<$)=jM_B_6&q~BNzZ5u~0{8QAdB)$2y)wy7+|layAl%r@uYe2OqSBBL)@G&!QZ{EIO0m zxa2HW;Fb2a#=3psmGgC~Yf;){I-M_EGzX@DZirMfnWUHdQMR(&_k4wu8 zO*@OfGm`M-TsOdRK#@9HQ2}vpgCwS5ky2|Ak)iM43^g307rpo$l3Eo@=WV3Y?h!TK z($!06%Gd;gV&u8lj{Kz`alFocbKkXUFB4fWVSFSb$Hr4o32=wNg!!LuLU{Z*LDucp zU-F`-ickSX09D0q>L0{?PjLnl)z3@kE=bpmk_<#r^%Sv%_A)ZfF~mP+h>OtnmsBKH z?f0u5Mo)wi5sZ9r_BPbiASE~^xAzrDT|nI@MR}W$28l^;2Fvuqy%FFC>c#m2Mvjo4 z^yAp@AF(U+r(vWa-~MypE82H=t1V-BDH8RitXBvXeFe-gzJQ$on7QbTL+PCh^qu+4 zp|4u#k6S^$pLU4j8l|v61~io(*h|)$kd_=g+c@7sCWF>t;`W&QV;&Wy7In!~n}oh| zv#U5FPT0lnzenY+<8GYC?&pW@lP2un22U8QLRTg z1SgF{)0RWPTX7(i_}et}+a>{=<>!}_0<4n@H=5nC-V?Tf$y z#mL3+Cyj{1_!`?>TWURl((2su`AlR-j16D(38EOUZc0O=xOU!=DRjOq%DbtkKD1&% zs7&abC*S&i2YJA?m5lSYgs#kJM>n2OB)U2+re(>eag_ei&^ALq3LMH z?9jvVJ~!?m_NouvAj%muu7_VHo~SD3SlHs6^mJfqm2)J{VFp|v?Cj&T|NaMrbb7UYr-{YO2{RZzP=H~@DK zJ8Wn+*rfsT#Glm{Lxm_&7Yr<)W#sOjqYTlmeL&W*bsJ&>pJC?Noa$JZH6a~^X?Dz zpB~Dz=`w38sG(krQ{o72sbCaI&!GxZa;Pj@geHWBs1K80C<@ z9F-B)I4*RN@?^v+dbpNpCqWAz?rBfB%RzCDl=2;{wJ&m;J92s}=~vCKos@KyjlYc; ze8u!CcNxBkozRtS7}zwdmZ%+;ddc2CX0Y1~9ib*1AjR`KA_em1+&z*ih> zP)G^<{sQH7b8q-x$};TTwnUh`Lo@i2G#CwO!;$;AYcoh);=~cdo;>>2>-{H)K!Mm%z|HU&489@N4vnZV zbey}8vmb4tKifJFw8d5k_kB^06^0e*PMj1E2hFpoi-#2`cPhgwf=!0A$y~!qZ@TSy zJZah?XRk-j%8K8{iZJ~e)>LU|mD%*9691Xq^w}o0m2adAq5~tHMT594#~tv`Az)&r z=aUZ1#qz?7$JRj4VCh*E)iNqC^F#??H9m(Lz<6-1-s1aO#JLKL93>zG4rFYy+SYv( z>ZV9ob>;r~DB9*qW`gvm-pvC$?atz1I&<|7mFAhh!2`~pR#NH57(pz`G2TbxS{J9m z|13W&Fk*QZz-yxIu0m8;@lpj5*Oj3i7@?gX()(#O()8psDyKL)rxrd30y|fMw!=3i z!7_vRK2a;B#J^1|9;Y@@@J#ULTB}^i1JJu3U*!6HZ@uv-N7n@;udGwr@#sRS-h4hh zF+%qZ{|Lfo6kHAe!9F!kDm#D6{Er}*@K`gcW$yrxLkh-|-ApiE!?_T6MK&oi)>?-a zs-w$rX%$X|ou3juPfupUxN;S?y_p>R$+rl&Bm{jAX$fq3*H&HTZc;>}dyQ7NG%6^X znkm^B-SMm%KK1*g`3nXkbAhxPkWYiil6d;sC%`%6fs(6Nr?O!mO!6Vv24z2!H-0?rfj zpGTEHSf5x;tv)mcvNx{euNtJRRu=TWQjUN^=a@~^8OLM?Iwx3z(1I{d=*_A6@XBBA}k#3fJ~xVB^moHo4+oW+5}gc}HQx2R zB^yTnX>vWKT<5E(>u=^*!(&-0C5$oam@0N2feasCJljo!%wyn){JFbjSUsDVrfC~6 zeEotu_N(gdPaQPy)iM+O=S&A)Vwp!IgT$VK*Cz`V&4M4wry&SJk`!3nzJGc6@Ug(X zv2XT-jO$ucx(NDjS`^%SE#=@eWJ=Yd+~#sMqdux5U-(o>#mND2IefC2Z(+MeCbwP4 z9f%GpVy^tgXwy(fg@@X3nCp|3)0e2*EgtSmmDi8BV;JJx_o)db=@z*#AWyhes!^b^ z(>Pk>Gqd)^1oR1Rs1hXLKMs`s^)u3?p7g?QdT{X62JC2$#3WGs3cipxI{B9F<3`zb zjoh3=!QL%Z+`9+^+=u1L!$ZMX#6T$qgM`NX4`0qdo(Cx9KJ&ZB$2uvEG?ET!{weqv z`Gtms^sxI-?i==7i|Lh;$J>AM>z}^TnaDfn#R@cH*b~sQ47L<>|2&jxHKiWzLKL#k%(@q-QZ&nDMQZz(DO@aK2h zZw<&B5M@u5wp%g1{v;xJh`QOgR* zqzZ!hC#Gy=UuuX->MKXoE6ObJoE0Rayi#C&WW3*xE(%W(;6OsXt43d#5mAoZ*H0PT zk0oO38?qP=eFRzgK;XALaP9E}iMrq>Cnz(MWDdyCa$Xn;7sb9CHP2I&N{z@S{)TSq zgstlkX@a)BcmBRT{pRpjM>OPF5tTM$&ILrrH{x4_vfLV|GSx{kZzjx>`J|oMg8TUl zUs&_%z*SK5+y3Eq`C@SA#R~{BwoW_70?EdZ(MF=auPnAu%D$%Bea#cJVA;WsdVK#T zRY`QQ2=oHN`}N1G&&u=OVrDL`nI}_9yRrt~WAtqy%`lJ{vw4~<8sNX@XKb6vgCyla z9P%I*`3u&E-GNwEAl(4_fJ3f<=3jM~G}A%$HC9LQ@{W6_Snp4udPt>eC3e1|im7D< z4j!5dqz24WrT`=4Ej8GVkTY+tOBQxzO3M-vHK{KWhVRe6UzZgv?i4VIH_BUmQ~f*b zYi-zKCh_WF@M$W9S3VjTDOGq)@^3x~={I$s%k#|@by@iYV%9RJ_z4$gGoF$oZi*Mn ze6g%r1 zk?e`+9jKrivNoxhJ36V3vQ=`ocf6<|FV{caa>E57uz*&mfwj(;vq~nL+LgkxiY4R) z&5V!whKlblq`Mh=pj40!IOrgwK?3|WI>mBascHolkVuOs(fUl$>*?$u6xCn^zCT@- z04op|{}uAk4k}AEvUo`wa|Zp{2pzUv84);m-3}FK(sB=nc@As31T}m~Ln_=ZCq^R@ z1eVt2hV|CLy4yf(5l@zOD`w?HR1bgQdnqm4H%ypsm>@Noj{Ko&kz*D7-Xtk8TO7i9 zF>7g@3MByv1CX*Ul$gR7WCAC)U@k%Vq!Cz%4-W1*!Jgb%p;1eJsMV!ZZ?dd! z&q{`kQaT4yCKVY6;}%UO;i*{!yrPdPl?0@Fzsd@rR#sE25`|Ep^TWT5Z@-hg@TT%& zmkPqTle4_|;!y_ww;+!J=q$v=cT&{}QA=-Oc}06ha)U-Ont_m=1$p`njBAT)EhTRv za-hl}Q&7G&$5(L@iM`toH?d)FY2t6jrsp3pop4&XNQ(uBzMv*PCL#Sb-yx@|29}6d z77!Hp&FQJgj*VzdqpbNuMn=G3oIZ;%vOR%uC%fKe3p*PqIb0M zH)nAWk*WJ91g+KU2=B80%q$8q#)L*Pn3u5hPRzUcvPbRW1-XwWZD0>!k)?%`#$O;$ zXUI&_mdFy?j-9=gr8(5{0NRs%Wh*ACtjb0!b{-uq{ zt3L951G({MI2#TLi=M3%O}k{x6|67tg87rK7+Yi!{}iFVvgx$-)F}{~Xtxon@2MTJ zXIK6(7yeDr3z)j(sO2Rr+1ccj^5N+#Q3!{nPq0e2?qpk_@$`XC<#YVT5~-;MN;A0mR=S zcZCQRj09&vfuxn~Hs0@1MOa}=(?yS-m2bo{EtDrw#(YP&8*%!wWD1t4HKArfa;f)( z!Rx^-RiA!uQI)XD1AIig^ZGx(fj=c~1q%OtkDvadqMQ|!An6N-am9_0*gzM(4&x9n zmHmS??vBPwPVEA(?2tp{MizVNIkjg4VW}|87CR|Rv54Au5s0rjFk9R#_jjvN@AWR< z&JLQxM-_HO19!EpY!nCCkArx|4fB#$@{_q@l7%9Y+oPp^K4*c8f|Bp4EpDC0fhb`h zd{HL>Qw^!2H?blnk?X(#HsRg-fR4X&4e%Fgd7GYv`r%^TN*~>|JOrZrz>(V2PHAzD z83WwK?xn@xN6P!LNgIXUpr#*OnNv#WCd|swI_lC5)J2MTQgvom~Az z9Hyiw`00|(B+=l+=4+BNqkN_YZKXf_T3D2Z%wm$Yp-6T+6s9|_%?tPNByv zw$Krz*3?NEpQ4x4#2>;KHLj4xJHntR2!bR`94Uv|QjYKl1T@IwUMy#3uH zw}AIjzwK0zB^s&o)-m%Np$|k|rV9a}6B-t$C)$j3!D9gg}OXu(7TDt!ukfXTB;|y7&a{B1q~#2m>e!jm7w9 z0%HaEL8CFy_Anir`~oc;@(*jSSnzU3jt1mNHlhnsA}mVMIXJQx@kUa=W?QQi@&{BqyLv95Wu?q~htEpa1o3o4!nHfykH`TPn9FAk%n0{J7h z*^%=x&RB^Pf@ypT$0l7N<{$p*et+(H`{2jV&H9u=FjH_;e%pd?*bZeT8DA;ju!E#; z>$xHeE`m~tfKEgx*58ZqVg$WzJWv8DV|2;?2iJG>SR*u%r|JsxgH6ha&hn3aUSW_e z$RRP8`s=+nhA|N@0Av@9#gGmnBJ2Ld9WxWG!kao4u$ALHB}1<|VZs>lbHmXR_wXBU z_6M1sPj7|TqESXl`-m6gyoXS?%2q>MyGC9 zDSS-f?nsqaxQzTaD=wkOs$WmtNc$sB>)&~|I1XzEY zyXuvG-M9B@kq07(zEqts_MP6n8IaV#wW2rBun^)y*~8mnJx}64cj;nlj|IH8yZ4{= znj%1UW#&7E2JQq9Z@-V}Ws$w;OR*M%{|2rK<>{L9*Kgy3WGCt%>p-708Te?d2x!ip zg0Nxo$lt<$Sl|3Sf9CJA3WPPZ!c&Zb$?zz4(IMATfrEqeg~$%&MO-CWL>PD;a4t}U zif?k)uiXv_ihF(|p51{2%#pmzW_+!BU>166#PVa#f1jB--%{}+7ZKJfrz1QSteay3 zxNh#ku;2yhV9K*+3>A9qrhdrSot1c*BR08B(Yg;nx!NOz$F%1e&~Kj3n=;a?g})N1auK; zu-Y5_Ass>4`W9p}Ffm{^pfw<6EV(8;@VT2dBoH#?*JojY*{xe)pP%qS=>2r3khk^0 zM>y^c*2iVMGcfnX!VYK*u*fdKKR#=XhMq6O1rb3crWqjRjWcx~eOVEf02l{sqa0pb z%lf=*s&AO~H^shvjeRlm9RFdw?KzNqCYcPmbokbfR`Kx{ym0aJ8!uP#?2H1vG=O}l zUaI@|)8N2K3~xt9Z8kAf6#yj0mnF4F_<&|0Y_tpVnd>#W?4}hydi5j%+s>B%ufE@Z zBBkyEeK59_0yRT|&cr*@1~?pPSiW$D<(yJv$PB0r_!~RIyrKSrJuqxXzcBEm{He(x zZ0lR!^1=asS^t$fc5rWqJU(K!!+WV@3?>Q66*+~shlqpWn1@08it|@-KKxaj z#|HR|Z?B;fr+wWrL6+^?PIc7}90d{GM5BBKXRx;|xn}BMf#5EBZR;wxLmmTSJ0kj_ zC!G^=jtjxt07;%*k8ih>9?1)~7wqKQfEUX~hz{{2!!`m7LPnn>*T90Y;B(#C0+Kq} zeIHxMO74r>^E1|IH)!kK${q?EDZc4=U+6djzPyukP^9hh-UD(b$n(Pk*5EsIPxN-a zwUHLzTKz7$a_FEulwk`O8FeQROp{lVASAoUU+!_-FI-qAf-?cSA}+Xx9OMKLKM4zF zn(B`c?v6Wpiq7g$YfdHM*m4boFZGh+0+y)Vo*$W8s$ad=rXQ=z?Ps>w(QkqlZxg#P z=Fdfa%%qprR$ixm!4K|xWChU&Ar0glgaqkZ;VDPKXcrrNsrCTGdw~DgZfC_VJtCYF zZfypS7W_pg$|JHO(tlGQ$)agnLhVQ1%kTG=Q!l1{p+W z7Q>{r`q4v=%&61`O_0j#6|!StgX8;t#Tmv*XFZ)qVss;T*8<7WAzA~hTF`R{fuj5JFXOY<|~jH z+udFD9#H9R_plr_%}6u>a+m=1>=1=>JH;6BJ7PGgF;6VAt2SJ^vHweaOu9>4?uODA}+NSR$pzIsk^@Hg~?k?g`mc{_0 zlc;bXQ(B=7$uq@@ya)QblCVEAKcc0R);$ypAHZtkG_btKgFLwNFJLM7D>= z?;$4z4+wPvc8tk<;eLemAe)w?{&%XyxX6gN*=MLjE=*WKI4Gxa3=d9>l15pZGeOnae^3Zljuko@+4)?^k(D~cMd{o!;5Q3ELN=) zv?zSx3GJkytl&Y&WC$6PEiy%LcRWrc7NBG;h|1vwic=C1?;#q4S|_)62vVnNYu`bCHAv;B5nqy|?{X zQZ_FKTz?5rfKI+gZ)JgPjT(Y|f%M3ZwDCIl0As-7!EOTjFyBV&xg6Ny`o>ZB$LKhC z%Q4SLK>sqbMd{zqetj{{_BOgQid>&VuY=)8q457ulHTCHYP7O{<~8~eJ!`S2A+(>0$~yPduPb4Qnv+dU)?N(0?b zY~fawvzvw%KCB)f$tTdp50S~w;9>cH+p~(_0Ih(R9D$;XX<-KhqG8$C;GL{(TX8Cb z_&@gEGpxz(`5H|R5JE!lkO&GYy(%pUv0y=hjb2owNk@81LRCZ%c?3lvSWyI2dJP~b zod`$|y-DxAyz%+H|8l;a@8^8v3dw!ld(Z5dS+izsRB@^G)J$l0nc$XJzJ+=sH`)1( zWu!Y~2ei*rAd3_UO`lEf4}00Pg~F`; ziTHCdIWoei3qK~dgt!8Oloo;gloEz`*FT=|jip21nY?FyKM4w8y_QWy>y15Y{L?bv z_`qwL!(6qVeZJvhmLx3i8FMQ4!+l?11X}#pn`${X@BP`Ss=d13Bi||gR_Xgh+V~ecYNmb*&`FPE1{o*RX8xE-0Xlen|Dq` z-Fo`4yEL|O_QP#i%f@cqz3bZ%)5lo5#x&|Lzt^9THPjMl{b{sMd*<}T?b5>k`FW?Z zZTo>Qp8fit7uJ%+06w^HF_#;=3-P=KQaL=-X5 zQEd7Di_p!CV8J_6Vgs zf+VVxe@!7l2jD638jI$N@B&_JBQ9Tc)a5frMeW&OlPPmP2~3f1O=UlCZWLG|#}8rI z=k;foN#)!Z7l}QPkrU`#E>A^+1MrR`L!H(Jy#^@+A8-X9pUCQCJ+1Ek2Z|;(bbwE3 z3KSdufRN6~0*^N#c|y`Qg&feUah&DHmh`|ry8ie2W^eIj;=pw9vu7(e?y|P!HfviD zkNwCWtj0V~O?SJ5)8NE*wq-ijTKM${S{FIegqE-m3VTA`3N$!JmOxFKE4lXvSgQj% zj}Ie(VOkI(h6%rlA_OYD)eK*AO4xx>k8D{`aYE%25=9 zVUt$fG;cVAT2cuvKQmxybjpB~8-L zi`$N3_U9Q5>nSsyU%t;lQ4iv z)U6&E6Je>m)48cSZy%Fi1ZDU_I^DtR^5_HMQB|xNa1COR&NV4zu-}o*neG%+bg+ZJ z&}dS?a-!HL0KA-1Gq0`hSMMKzUuI7~rmh5$T8U1AvX#JWz45g00iOg5HZM`atflN6 zWW+A!hyIGf^d*->!9|XxO={=KgkG?-X5`KhwZ~CQS7Q!AJDaqh6l2?*FL6Ts;pUv3 zYD*HJJh>Fa!6x(e)yf4cJ#486JD`?2Mo{u`t2TWjuEB-!iu*|*of0$Gr0gv)+S%mY z^)IoT|IlCXkbTMV?jEKTHhQi4(KlpFAebtO-8ZR!$-XW~80$h0vR@Eb;~Q2U_N2j6 z^2pURF4#JM=DljsiNXOLPC4vER^wk2WmQuGVt$l{{@=r82G&v1cdCj}hVUG-AZLv6bYe;#&2K`J}a}$H`g}<$~3*)&0BpLWOhq+0K9d^F?EW$DlkJhG1ve@FLgX$ze$+ z-k)E-Sk=BiPdUxmd1M%loL$L?#Uj{j&*T)AXS z^YRHpZHED`e`Zg+ah3Ek`Z((#jp_J&sao#ZDgAG_lxuV7Z+dm_!*8TgMYsmx!&6ne zpz?;QbH0wIiVKzQ4w{SQGH>Ifn-6<~*LMtq;tsu0R~x%4{^#{@l*-Qc(MVL@h1)A9 zd$VuLA1$+$3n~`ZXsx&>8m}KF>&`av5sgD#Krz@;I;wQ$TLb;#48CdAuA?8r7xhTq z^D~~hu(g{BE{Gj9k}hUTtbGyR@|$DzHTw<&mL6q9of1o%393bB@$s$qfGUHG0!Y|-C+v~Va%8;c@U3XIYTUJ*>{kI03$MIznga=@d!I`?XwRqyIxo-aoYzVk>0s8J(Okw2KIYtPXmx8zyEc;qVRyBG!8)< zckl1XTwjGpIX?7-jBYph7Wg0c^8V4*pZ4Z))n~{tP-{_il@dc8(=mVVSbl763cGz6 zqpi4nb&4XvHHSDLl7ck(HltSlpcP=ta*U~PC#ZK;Qm*sgO&jysfU+qM>D=ikm|36u z=&?M-T2FtDq@1X$Z{W6c_7S^VXLnEQf)j6{ z6*)acY6N6uNP~cjHpC0WKOnaFh@}%TxCpzLOR8lo4?k}64{Og9{@pWwMDw7VcDH7s zpWtR*$;g{~F7YZN93u)WpTDnAo2uW=oA`iMQ~<9}DY1q1st&ivmT5AZfvZLgi_)|^ z;9609Fzn1Cu~;`^A7d%v=a@RSgf9`W)=Qfm5L>>kgb42yYSy!T40`Mp*M816in`sIBik)(a3vaI zpg`T)L~oM)m1=|VD_qk|!cST*Fi+Y;UFpVu1p;-z?do51tw~{Q%@(%DdL=R{d)6Uz z5EI;tPL=-gl4H`W$UOM-D^-nHP!n*U2~>WNdcIP)+0M35`^d&kZL!Zr&}=J?{zoBU zx@1sC?&G5P28;+l+PY3$YqI2#IUUpZj$aeGlEWw$cix9!4t}2Tp!-E2nc;H^1I=t{}NU6dm45Tj$VuIJX5d3uFl~coOzm~JuGT16fFll5FFNJ|HR*O@xR`U zn{{~gaCfc@zXDW~48p+bn}m5u+CdxRi*lzs!iV)&3F#?yO(UUHf_jX!*b`uX#pC-g zGa5h+IipV^R`0_0&SHwe?bB1sE^!FP{exS}AuIH)PeQrodwKlBNpdazi z$_=N#+23CSQR>W~J%)#IyxqkgLlwjlLVzMg)agq~r~SHim50N?)>07;cz@uuSc+Vx zvh54-@&hP$a+ zpcJWztS(mS@SpQbra#i6dOjf#;%q|Maiw{xMfmBffICNqL%0sk4OdJE_`QMzYBMx@ z%a>eWmYn4ytYmDSs%UHv7h%cW0#bhTE?_^UdrD6KNNDeFM9|UMiUYR)kiI8MIMe#M2Hmrk8VAnbp|! zY0u}Z%seWJuM@{n5KC;KB(&lgfP-q8Owy0MHb+?p^bgj{(T6{$h3+^o_MFwK;d>x2uuKp>c~knV^| zKVrJTG!s(coH2Eum1OzzsmY<%(+Tq#B-VY-#wcr8*!iU$%FL}?<3^Kr|JD%y9s`ih zYi_AMBm*RHl|6-*0;CCm6aW%M4Y$e%hl}uiN1q)}W1En|e2N~2D188itdUcEQ5XC#{9_ONn+kkhXm{N|>cvjimY>meSfnIH5AR_JO_3hsp z@?6^#Qj-I(htE^L_MK;bStjHx7k5aU8k@J9%Khz6xxH>JN z1$`Sr_sm(3yAPv2wQ5^78lI|Ngj^h<~B7!iOBB963mIGboYR zyM!V6Dx4bRO|-&>LK4Hm^?oj#)HEWSaW%6AC5oNzlU8%)T}p#4n)VAp%P3*lhCE}E zsL+NSflX^x8kgFW_C|~Qx^QPe)vNz{r#TR-RIeLoIWYA59>%q&TV*2z0)G^#|BO)2 zB{GjPJl-}ziXdx&B7vItbe**liJh+R`gaBzJ)t2A5qjx_#hcSUGHUCTA8{GCY%;Y` z&enyCuRawZljv+)V}uIZskq-38-1q#9cusDp%7?}bC0W2n)DNRR7ntk0{c+{fW@_G zB0n$K`W78`Y8mh5MoF8HT8=D}@(sceYlU7QGJn zoT@4@ANV-7EV}>3F@U8LCim2ltrA4X;^kY^=p`cm?e7}-f zomEvcc5BOcBVg&o#`^nBOofq*puUODqp}7#(^2OV*t4!6h#$p5u{N(w%u0_GunN#- zAxMXHWLs`@n?inDhjl@2ksP?L1U>K2W=v|LhUHt>o)+#r_iDe1pMI2;Los84Jed0^ z1g`@g|`9|F4Wy)p0z-C_|BIy*I5YJ z|3Fx~#`95@6>eRtSLbso_Wz%s1+n0*!u^Sv9Oc6u8z)hXF8;e8UCz7>w(2+f$0){4 z|1U&$eek|vnS_}iql=uv75M#XWzOPiG|A%BWI<4N*|ehp3swvHA6U)A zuX6d|<-Oz;A1mf|U!;fK@WuZh5&eII0+SfMf*36dP%WX@VcvR`%*kMrgK&bM!On%7 zgFEGHFFM%lwaMA^6HDOyZA^;O<{|S>-`S}i0E>va18M^*7HgF!WOWFe{4_jUVRT}K zo~gxZy@-Nq@xXeJH$?z*U-Q9}Dm>{t#a#Tllz%E&OPtlwv4%(a`C>!$A^D}Lsi?P1 zsp(@#r~bIpAK9AQnd5zDKP+3reNdUXR6owGCrm;$8Imr!Mr)7s{Da9zKR$Ok#ibcwy=!%U7rhv8Sq8H`DCH^2>itc;#rScxoX3-74~f$8wauyyIoG};Dioz0)4T%Djh& zsQqarAKS_M8+C`5DJMl2PLDNqi%&9cuzt8PWWcIRA?Aw1WN~S!Sci3!J1(;6P=Q42ch-Jnk>UXv1Mn&Vu8A3BiAwaXAp1QX+UBHHh@3T6HNy< zOt&gjwK1j}3m*kZ3^xu~xp^G-tEeDX;*Fjk3gYC2=Vzu*nK*0~`t(@3&#EPtX$^k= zz5OI7lylC0o!G4Tx0x7BLI8s(eL~GYSrp>O&@`YoTS^P&v`Atl+KPPymvLX0j|ID8 zN!qMt7d_}w9=r4yAs!~N8`=GV`Ep!rQ?}$*(^;|)eja6sm^n^br0fWFN)J1cx95?v@;u^VW1w@sGj168>UT^EF?BkWKN8;jd-j z&f@4fwdoy=FIoGneveiB(??OGva;5&cW|tZJ;!Npo!Wm*?b}45oJqGb_nq9B+UmjE*btpbq*NK+dMsjmpRrPY~5h1r>8XuEXYNlg;5-!Qv9leL8bi7N=iYL0RA2(6q;U! z&*FCUsHlpxdz+DUEw(>9^nh<^Fe(vZSXV(|q?f#tSSLUtWYfu+~U6#rOZ5ipe_&ZLcDsbdbsawO|N=xT< z2i)I=RAkWiwGJ=rhP?k(86VnFnfrVTp{N8O;F?tH&ziV9>_^jsEV?bFMbY4IXD}gB zZ-*i~;}UZ6a)XS*MSl0vC(?J$J0=HD(4U9RL$$ySkqMt67l4T<%yZBM&IYdp&PY2f z3#qr3OuXhoqL~1_Q7IFYlU8`;o@_3gpN7xvSnDGd`pEqIWC<{qbZm3L4ccUu^JJ5|L2DsAsEpy`4waOw!i=?FhzuyZ8-3g*P z-?2?{wfz0e_ZR6K?-^0zpSvt-f_ySQ@J9bhMR!hCe}Cu9QXXv(sLIj&gBBeg%H_W< zXgzc}HNRGe%U*0L$~C3)!tkNQRZ;v-V5S*zMuF0Ijka;O?30-lYEUS7_-)?)^<(r} zWc9VL2Fp{X9UJ58#^^=F6V$I%bOqA*hwcvkI((T2Ee>Ck@w1l}+)hProJC96*QN*V;FD1R$f(E=cJhChn|tZRf&{eImX*Z2a6~V0>svQ zsy3={u2oOWLC##{AIywDN-@s>8x&h{14-B)Zd-x*IU5gc*C}omwGHsk6#t&4dpBxL z9*qiLGL#(z)gftLj`MxMFB{5+a?}aPa*;>>Az33Ev+$?Dqde1KLXkXX_RgV1=RsA8 zTQ_C8G^%8cCB!6+tC+uu6YqhBO`N0^m(6caJtNQQk%Sy(@3-H?-30TOluCw|B0E#R zv3(J_03hAHE}!z9GB&p-89rBDCW6wr70vU8nNj>=e`j1+wk2}FJfWAD%3E#I9kTz& zH?l++?lLv#H>p@&)$KZF_ZS!gPf>MI{Aq(~@9#Sv^F9t6To)Q}0;TRw%=XoN`ATb7 zbKpR5aG@m5#(4K2wxxI{H0F@=R~>0blut2Y513%XWsYEINth=`b!o|dhGxSaj%BPJ zNs=LSHZ3eMSY*M6#u^iO=7`SUZpyUjQUR}${UOLY$^DKPm9C

Y}f`ccp@3{wu4_{k>R}9RdzKO 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 P00000NkvXXu0mjf>Tm}# diff --git a/chat/public/icons/icon-maskable-512.png b/chat/public/icons/icon-maskable-512.png deleted file mode 100644 index 00b2c06204b0f5a15ac54e4badc71aeca45e8b33..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81767 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

;?+~PN@>Sn6?Z$9}!otF~ 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

Y}f`ccp@3{wu4_{k>R}9RdzKO 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 P00000NkvXXu0mjf>Tm}# diff --git a/chat/public/manifest.webmanifest b/chat/public/manifest.webmanifest deleted file mode 100644 index 51bfbd2..0000000 --- a/chat/public/manifest.webmanifest +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "/chat/", - "name": "Xiteng Chat", - "short_name": "Chat", - "description": "本地优先、可选登录同步的轻量 AI 对话界面", - "start_url": "/chat/", - "scope": "/chat/", - "display": "standalone", - "launch_handler": {"client_mode": "navigate-existing"}, - "background_color": "#f4f4f0", - "theme_color": "#171717", - "orientation": "any", - "lang": "zh-CN", - "categories": ["productivity", "utilities"], - "icons": [ - {"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, - {"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"}, - {"src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} - ] -} diff --git a/chat/public/sw.js b/chat/public/sw.js deleted file mode 100644 index d414e35..0000000 --- a/chat/public/sw.js +++ /dev/null @@ -1,58 +0,0 @@ -const cachePrefix = "xiteng-chat-"; -const cacheName = `${cachePrefix}shell-v8`; -const basePath = "/chat"; -const shellAssets = [ - `${basePath}/`, - `${basePath}/manifest.webmanifest`, - "/favicon.svg", - "/icons/favicon-32.png", - "/icons/icon-192.png", - "/icons/icon-512.png", - "/icons/icon-maskable-512.png", - "/icons/apple-touch-icon.png" -]; - -self.addEventListener("install", (event) => { - event.waitUntil(caches.open(cacheName).then(async (cache) => { - for (const asset of shellAssets) { - try { - const response = await fetch(asset, {cache: "reload"}); - if (response.ok) await cache.put(asset, response); - } catch {} - } - }).then(() => self.skipWaiting())); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil(caches.keys() - .then((keys) => Promise.all(keys.filter((key) => key.startsWith(cachePrefix) && key !== cacheName).map((key) => caches.delete(key)))) - .then(() => self.clients.claim())); -}); - -self.addEventListener("fetch", (event) => { - const request = event.request; - if (request.method !== "GET") return; - const url = new URL(request.url); - if (url.origin !== self.location.origin || url.pathname.startsWith(`${basePath}/api/`) || url.pathname.startsWith("/outpost.goauthentik.io/")) return; - - if (request.mode === "navigate") { - event.respondWith(fetch(request).then((response) => { - if (response.ok) { - const cacheable = response.clone(); - void caches.open(cacheName).then((cache) => cache.put(`${basePath}/`, cacheable)).catch(() => {}); - } - return response; - }).catch(async () => (await caches.match(`${basePath}/`)) || Response.error())); - return; - } - - if (shellAssets.includes(url.pathname) || /\.(?:js|css|png|svg|ico|webmanifest)$/.test(url.pathname)) { - event.respondWith(caches.match(request).then((cached) => cached || fetch(request).then((response) => { - if (response.ok) { - const cacheable = response.clone(); - void caches.open(cacheName).then((cache) => cache.put(request, cacheable)).catch(() => {}); - } - return response; - }))); - } -}); diff --git a/chat/src/client.ts b/chat/src/client.ts deleted file mode 100644 index 2b321d2..0000000 --- a/chat/src/client.ts +++ /dev/null @@ -1,2608 +0,0 @@ -import DOMPurify from "dompurify"; -import {unzip} from "fflate"; -import {marked} from "marked"; -import {applyBrowserProviderSettings} from "../lib/browser-provider-settings"; -import {createBrowserProviderFetch} from "../lib/browser-provider-fetch"; -import { - commitConversationMessage, - createConversationHistory, - deleteConversationHistory, - getConversationHistory, - listConversationHistory, - moveConversationHead, - synchronizeConversationRepository, - updateConversationHistory -} from "../lib/conversation-client"; -import {conversationHash, conversationIdFromHash} from "../lib/conversation-hash"; -import {conversationTitlePrompt, normalizeGeneratedConversationTitle, untitledConversationLabel} from "../lib/conversation-title"; -import type {Conversation, ConversationSummary, MessageCompletion, ResponseMetadata, StoredChatMessage, WorkingItem} from "../lib/conversation-types"; -import {defaultGenerationSettings, type GenerationSettings, type ReasoningLevel} from "../lib/generation-settings"; -import { - activateOfflineProfile, - cachedLastFetchAt, - cacheChatConfig, - listCachedMessages, - listWorkingItems, - loadCachedChatConfig, - mergeOfflineProfiles, - removeWorkingItem, - saveWorkingItem -} from "../lib/offline-history"; -import { - deleteLocalCredential, - getLocalCredential, - listLocalCredentials, - saveLocalCredential, - type LocalCredential -} from "../lib/local-credentials"; -import type {ChatProfile} from "../lib/profile-types"; -import type {ProviderDefinition, ProviderModel, ProviderSecret} from "../lib/provider-types"; -import {responseMetadata} from "../lib/response-metadata"; -import {createMessageObject} from "../lib/message-object"; -import { - conversationTransferDocument, - parseSessionTransfer, - serializeSessionTransfer, - serializeXitengArchive, - type SessionTransferFormat, - type TransferDocument, - type TransferNode -} from "../lib/session-transfer"; -import {protectMath, restoreMath} from "../lib/math-markdown"; -import {IncrementalMarkdownCache} from "../lib/incremental-markdown-cache"; -import {mergeMessageGraph, messageChildrenInGraph, messagePathInGraph, newestBranchTipInGraph, rootEditAlternativesInGraph} from "../lib/message-graph"; -import {compactModelName} from "../lib/model-display"; -import {applyImportTitleTemplate, importFileStem, importSourceFolder} from "../lib/import-title-template"; -import {shouldOpenFullscreenEditor} from "../lib/fullscreen-editor"; - -type ChatProvider = ProviderDefinition & {models: ProviderModel[]; modelDiscoveryError?: string}; -type ChatConfig = {providers: ChatProvider[]; profile: ChatProfile}; -type ServerChatConfig = ChatConfig & {identityKey: string}; -type PublicChatConfig = {providers: ProviderDefinition[]}; -type CachedChatBootstrap = {config: ChatConfig; frontendProviders: ChatProvider[]}; -type StreamEvent = {type: string; text?: string; error?: string; metadata?: ResponseMetadata}; -type StreamRequestContext = {provider: ChatProvider; model: string; conversationId: string; generationSettings: GenerationSettings}; -type HashNavigationMode = "push" | "replace" | "none"; -type MathJaxRuntime = { - startup?: {promise?: Promise}; - typesetPromise?: (elements?: Element[]) => Promise; - typesetClear?: (elements?: Element[]) => void; -}; -const basePath = "/chat"; -const appUrl = (pathname: string) => `${basePath}${pathname}`; -const mathJaxAssetPath = `${basePath}/assets/mathjax/4.1.3`; - -const rootElement = document.querySelector("#app"); -if (!rootElement) throw new Error("Application root is missing"); -const root: HTMLDivElement = rootElement; - -const state = { - config: null as ChatConfig | null, - frontendProviders: [] as ChatProvider[], - localCredentials: [] as LocalCredential[], - conversations: [] as ConversationSummary[], - conversation: null as Conversation | null, - providerId: "", - model: "", - generationSettings: {...defaultGenerationSettings}, - recentModelKeys: [] as string[], - historyOpen: window.matchMedia("(min-width: 681px)").matches, - offline: false, - loading: true, - error: "", - modelQuery: "", - streaming: false, - streamController: null as AbortController | null, - workingItems: [] as WorkingItem[], - activeDraftId: "", - historyTree: window.localStorage.getItem("xiteng-chat-history-tree") === "1", - advancedActions: window.localStorage.getItem("xiteng-chat-advanced-actions") === "1", - identityKey: "", - authenticated: false, - syncing: false, - syncRequested: false, - initialFetchComplete: false, - lastFetchAt: "", - syncError: "", - renderFrame: 0, - settingsTimer: 0, - syncTimer: 0, - workingSaveTimers: new Map(), - messageGraph: [] as StoredChatMessage[], - previewHeadId: "", - queuedDraftId: "", - importPanelOpen: false, - importing: false, - importStatus: "", - importTitleTemplate: window.localStorage.getItem("xiteng-chat-import-title-template") || "{title}", - composerFullscreen: false, - settingsOpen: false -}; -const titleGenerationConversationIds = new Set(); - -const icons = Object.fromEntries(Object.entries({ - history: '', - plus: '', - close: '', - trash: '', - down: '', - search: '', - clock: '', - more: '', - settings: '', - send: '', - stop: '', - copy: '', - retry: '', - reply: '', - edit: '', - tree: '', - list: '', - scroll: '', - offline: '', - transfer: '', - upload: '', - expand: '', - collapse: '' -}).map(([name, markup]) => [name, markup.replace("; -function uuid() { - if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); - const bytes = crypto.getRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")); - return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; -} - -function escapeHtml(value: unknown) { - return String(value ?? "").replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]!); -} - -function messagePartText(message: StoredChatMessage, type: "text" | "reasoning") { - return message.parts.filter((part) => part.type === type && typeof part.text === "string").map((part) => String(part.text)).join(""); -} - -function knownMessageMap() { - return mergeMessageGraph(state.messageGraph, state.conversation?.messages || []); -} - -function conversationGraphObjects(conversation: Conversation) { - if (!conversation.headMessageId) return []; - const messages = [...knownMessageMap().values()]; - const adjacent = new Map>(); - const connect = (left: string, right: string) => { - if (!adjacent.has(left)) adjacent.set(left, new Set()); - if (!adjacent.has(right)) adjacent.set(right, new Set()); - adjacent.get(left)!.add(right); - adjacent.get(right)!.add(left); - }; - for (const message of messages) { - if (message.parentMessageId) connect(message.id, message.parentMessageId); - if ("sourceMessageId" in message.origin && message.origin.sourceMessageId) connect(message.id, message.origin.sourceMessageId); - } - const selected = new Set(); - const pending = [conversation.headMessageId]; - while (pending.length) { - const id = pending.pop()!; - if (selected.has(id)) continue; - selected.add(id); - for (const neighbor of adjacent.get(id) || []) pending.push(neighbor); - } - return messages.filter((message) => selected.has(message.id)); -} - -function downloadText(filename: string, text: string, type = "application/x-ndjson") { - const url = URL.createObjectURL(new Blob([text], {type})); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); -} - -function exportFilename(name: string, suffix: string) { - const safe = (String(name || "").trim() || "xiteng-chat").replace(/[\\/:*?"<>|\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "xiteng-chat"; - return `${safe}.${suffix}`; -} - -async function exportSessions(format: SessionTransferFormat) { - if (format === "xiteng") { - const conversations = await Promise.all(state.conversations.map((summary) => getConversationHistory(summary.id))); - const [objects, workingItems] = await Promise.all([listCachedMessages(), listWorkingItems()]); - downloadText(exportFilename("xiteng-chat-backup", "xiteng-chat.json"), serializeXitengArchive(conversations, objects, workingItems), "application/json"); - return; - } - if (!state.conversation) throw new Error("当前没有可导出的会话"); - const document = conversationTransferDocument(state.conversation, conversationGraphObjects(state.conversation)); - downloadText(exportFilename(state.conversation.name, `${format}.jsonl`), serializeSessionTransfer(document, format)); -} - -async function materializeTransferNodes(document: TransferDocument) { - const mappedIds = new Map(); - const stored = new Map(); - const pending = new Map(document.nodes.map((node) => [node.sourceId, node])); - while (pending.size) { - let progressed = false; - for (const [sourceId, node] of [...pending]) { - if (node.parentSourceId && pending.has(node.parentSourceId) && !mappedIds.has(node.parentSourceId)) continue; - const parentMessageId = node.parentSourceId ? mappedIds.get(node.parentSourceId) || null : null; - const message = await createMessageObject({ - parentMessageId, - role: node.role, - parts: node.parts, - origin: node.origin || (node.role === "user" ? {type: "user", sourceMessageId: sourceId} : {type: "legacy"}), - completion: node.completion || {status: "complete"}, - createdAt: node.createdAt, - completedAt: node.completedAt, - ...(node.metadata ? {metadata: node.metadata} : {}) - }, state.identityKey); - mappedIds.set(sourceId, message.id); - stored.set(message.id, message); - pending.delete(sourceId); - progressed = true; - } - if (!progressed) throw new Error("导入文件的消息父节点形成循环"); - } - return {mappedIds, messages: [...stored.values()]}; -} - -function transferSessionSourceIds(document: TransferDocument, headSourceId: string | null) { - if (!headSourceId) return new Set(); - const adjacent = new Map>(); - const connect = (left: string, right: string) => { - if (!adjacent.has(left)) adjacent.set(left, new Set()); - if (!adjacent.has(right)) adjacent.set(right, new Set()); - adjacent.get(left)!.add(right); - adjacent.get(right)!.add(left); - }; - for (const node of document.nodes) if (node.parentSourceId) connect(node.sourceId, node.parentSourceId); - const selected = new Set(); - const pending = [headSourceId]; - while (pending.length) { - const id = pending.pop()!; - if (selected.has(id)) continue; - selected.add(id); - for (const neighbor of adjacent.get(id) || []) pending.push(neighbor); - } - return selected; -} - -function uniqueImportedName(source: string, reserved: Set) { - const base = String(source || "").trim() || "导入的会话"; - if (!reserved.has(base)) { - reserved.add(base); - return base; - } - let index = 2; - while (reserved.has(`${base} (导入 ${index})`)) index += 1; - const name = `${base} (导入 ${index})`; - reserved.add(name); - return name; -} - -async function importTransferDocument(document: TransferDocument, reservedNames: Set, source: string, firstIndex: number) { - if (!document.sessions.length) throw new Error("导入文件中没有会话"); - const {mappedIds, messages} = await materializeTransferNodes(document); - const messagesById = new Map(messages.map((message) => [message.id, message])); - const conversationIds = new Map(); - const imported: Conversation[] = []; - for (const [sessionIndex, session] of document.sessions.entries()) { - const headMessageId = session.headSourceId ? mappedIds.get(session.headSourceId) || null : null; - const sessionMessages = [...transferSessionSourceIds(document, session.headSourceId)] - .map((sourceId) => mappedIds.get(sourceId)) - .filter((id): id is string => Boolean(id)) - .map((id) => messagesById.get(id)) - .filter((message): message is StoredChatMessage => Boolean(message)); - const sourceTitle = session.name.trim() || "导入的会话"; - const importedTitle = applyImportTitleTemplate(state.importTitleTemplate, { - title: sourceTitle, - format: document.format, - file: importFileStem(source.replaceAll("\\", "/").split("/").at(-1)?.trim() || source), - folder: importSourceFolder(source), - date: session.createdAt.slice(0, 10), - model: session.model, - provider: session.providerId, - index: firstIndex + sessionIndex - }); - const created = await createConversationHistory( - session.providerId, - session.model, - session.generationSettings, - uniqueImportedName(importedTitle, reservedNames), - headMessageId, - sessionMessages - ); - conversationIds.set(session.sourceId, created.id); - imported.push(await getConversationHistory(created.id)); - } - for (const item of document.workingItems || []) { - const conversationId = conversationIds.get(item.conversationId); - if (!conversationId) continue; - await saveWorkingItem({ - ...item, - id: uuid(), - conversationId, - observedHeadId: item.observedHeadId ? mappedIds.get(item.observedHeadId) || null : null, - ...(item.editSourceMessageId ? {editSourceMessageId: mappedIds.get(item.editSourceMessageId)} : {}) - }); - } - return imported; -} - -function sessionFileCandidate(name: string) { - const lower = name.toLowerCase(); - return lower.endsWith(".json") || lower.endsWith(".jsonl"); -} - -function archiveFileCandidate(name: string) { - return name.toLowerCase().endsWith(".zip"); -} - -type ImportSourceFile = {file: File; source: string}; - -async function filesFromZip(archive: File) { - const archiveBytes = new Uint8Array(await archive.arrayBuffer()); - let acceptedBytes = 0; - let acceptedFiles = 0; - const entries = await new Promise>((resolve, reject) => { - unzip(archiveBytes, { - filter(entry) { - if (!sessionFileCandidate(entry.name) || entry.originalSize > 64 * 1024 * 1024) return false; - if (acceptedFiles >= 2_000 || acceptedBytes + entry.originalSize > 512 * 1024 * 1024) return false; - acceptedFiles += 1; - acceptedBytes += entry.originalSize; - return true; - } - }, (error, result) => error ? reject(error) : resolve(result)); - }); - return Object.entries(entries).map(([name, bytes]) => { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return { - file: new File([copy.buffer], name, {type: name.toLowerCase().endsWith(".jsonl") ? "application/x-ndjson" : "application/json"}), - source: `${archive.name} / ${name}` - }; - }); -} - -async function expandImportFiles(files: Iterable) { - const expanded: ImportSourceFile[] = []; - for (const file of files) { - if (sessionFileCandidate(file.name)) expanded.push({file, source: file.webkitRelativePath || file.name}); - else if (archiveFileCandidate(file.name)) expanded.push(...await filesFromZip(file)); - } - return expanded; -} - -async function filesFromDirectory(handle: FileSystemDirectoryHandle) { - const files: ImportSourceFile[] = []; - let visited = 0; - const walk = async (directory: FileSystemDirectoryHandle, prefix: string): Promise => { - const entries = (directory as FileSystemDirectoryHandle & {values(): AsyncIterableIterator}).values(); - for await (const entry of entries) { - visited += 1; - if (visited > 25_000) throw new Error("文件夹内容过多;请直接选择 sessions 或 projects 目录,而不是整个主目录"); - const path = `${prefix}/${entry.name}`; - if (entry.kind === "directory") { - if (["node_modules", ".git", "cache", "logs"].includes(entry.name.toLowerCase())) continue; - await walk(entry, path); - } else if (sessionFileCandidate(entry.name)) { - if (files.length >= 2_000) throw new Error("检测到超过 2000 个会话文件;请分批选择更小的目录"); - files.push({file: await entry.getFile(), source: path}); - } - } - }; - await walk(handle, handle.name); - return files; -} - -async function importSessionFiles(files: Iterable, sourceLabel: string) { - const candidates = [...files]; - if (!candidates.length) throw new Error("没有找到可导入的 .json 或 .jsonl 会话文件"); - applyImportTitleTemplate(state.importTitleTemplate, {title: "标题", format: "codex", file: "rollout", folder: "sessions", date: "2026-08-13", model: "model", provider: "provider", index: 1}); - state.importing = true; - state.importStatus = `正在扫描 ${sourceLabel} 中的 ${candidates.length} 个候选文件…`; - renderApp(); - let imported = 0; - const formats = new Set(); - const failures: string[] = []; - const reservedNames = new Set(state.conversations.map((item) => item.name)); - let firstConversationId = ""; - for (const [index, candidate] of candidates.entries()) { - state.importStatus = `正在导入 ${index + 1} / ${candidates.length}:${candidate.source}`; - updateImportStatus(); - try { - const document = parseSessionTransfer(await candidate.file.text(), candidate.file.name); - formats.add(document.format); - const conversations = await importTransferDocument(document, reservedNames, candidate.source, imported + 1); - imported += conversations.length; - firstConversationId ||= conversations[0]?.id || ""; - } catch (error) { - failures.push(`${candidate.source}:${error instanceof Error ? error.message : "无法解析"}`); - } - } - try { - state.conversations = await listConversationHistory(); - state.messageGraph = await listCachedMessages(); - if (firstConversationId) await selectConversation(firstConversationId); - if (imported) scheduleRepositorySync(); - const formatLabel = formats.size ? ` · ${[...formats].join(" / ")}` : ""; - state.importStatus = `已导入 ${imported} 个会话${formatLabel}${failures.length ? `;跳过 ${failures.length} 个无法识别的文件` : ""}`; - if (failures.length) state.importStatus += `\n${failures.slice(0, 3).join("\n")}${failures.length > 3 ? `\n另有 ${failures.length - 3} 个…` : ""}`; - } catch (error) { - state.importStatus = `导入在保存阶段中断:${error instanceof Error ? error.message : "未知错误"}`; - throw error; - } finally { - state.importing = false; - renderApp(); - } -} - -function updateImportStatus() { - const status = root.querySelector(".session-import-status"); - if (status) status.textContent = state.importStatus; -} - -async function chooseImportDirectory() { - const picker = (window as Window & {showDirectoryPicker?: (options?: {mode?: "read"}) => Promise}).showDirectoryPicker; - if (!picker) { - root.querySelector('[data-action="session-directory"]')?.click(); - return; - } - try { - const handle = await picker({mode: "read"}); - state.importing = true; - state.importStatus = `正在只读扫描文件夹 ${handle.name}…`; - renderApp(); - const files = await filesFromDirectory(handle); - await importSessionFiles(files, `文件夹 ${handle.name}`); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") return; - state.importing = false; - state.importStatus = `文件夹扫描失败:${error instanceof Error ? error.message : "未知错误"}`; - renderApp(); - throw error; - } -} - -function messagePathTo(headMessageId: string | null) { - return messagePathInGraph(knownMessageMap(), headMessageId); -} - -function displayedMessages() { - if (!state.previewHeadId) return state.conversation?.messages || []; - return messagePathTo(state.previewHeadId); -} - -function messageChildren(parentMessageId: string | null) { - return messageChildrenInGraph(knownMessageMap(), parentMessageId); -} - -function newestBranchTip(startId: string) { - const currentIds = new Set((state.conversation?.messages || []).map((message) => message.id)); - return newestBranchTipInGraph(knownMessageMap(), startId, currentIds, state.conversation?.headMessageId || null); -} - -function markdown(value: string) { - const {source, fragments} = protectMath(value); - const html = marked.parse(source, {async: false, gfm: true, breaks: false}) as string; - return { - html: DOMPurify.sanitize(restoreMath(html, fragments)), - hasMath: fragments.length > 0 - }; -} - -function markdownRenderKey(value: string) { - let hash = 2166136261; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return `${value.length}-${(hash >>> 0).toString(36)}`; -} - -type RenderedMarkdownBlock = { - id: string; - index: number; - type: string; - source: string; - html: string; - renderKey: string; - hasMath: boolean; - stable: boolean; -}; - -const streamingMarkdownCaches = new IncrementalMarkdownCache>(); -const finishingMarkdownMessages = new Set(); -const markdownRenderMetrics = { - lexMs: 0, - parseMs: 0, - blocksParsed: 0, - blocksReused: 0, - domBlocksUpdated: 0, - mathTypesets: 0 -}; -(window as typeof window & {__xitengChatRenderMetrics?: typeof markdownRenderMetrics}).__xitengChatRenderMetrics = markdownRenderMetrics; - -function renderMarkdownBlock(source: string, messageId: string, index: number, type: string, stable: boolean): RenderedMarkdownBlock { - const startedAt = performance.now(); - const rendered = markdown(source); - markdownRenderMetrics.parseMs += performance.now() - startedAt; - markdownRenderMetrics.blocksParsed += 1; - return { - id: `${messageId}:markdown:${index}`, - index, - type, - source, - html: rendered.html, - renderKey: markdownRenderKey(source), - hasMath: rendered.hasMath, - stable - }; -} - -function markdownBlocks(value: string, messageId: string, complete: boolean) { - if (complete && !streamingMarkdownCaches.has(messageId)) { - return [renderMarkdownBlock(value, messageId, 0, "document", true)]; - } - - const result = streamingMarkdownCaches.render( - messageId, - value, - (source, type, index, stable) => renderMarkdownBlock(source, messageId, index, type, stable), - (durationMs) => { markdownRenderMetrics.lexMs += durationMs; }, - complete - ); - markdownRenderMetrics.blocksReused += result.reused; - return result.blocks; -} - -function markdownBlockMarkup(block: RenderedMarkdownBlock) { - return `

${block.html}
`; -} - -function isStreamingAssistant(message: StoredChatMessage, index: number) { - return state.streaming - && state.conversation?.messages.at(-1)?.id === message.id; -} - -function renderMarkdownContainer(value: string, messageId: string, complete: boolean) { - const blocks = markdownBlocks(value, messageId, complete); - return `
${blocks.map(markdownBlockMarkup).join("")}
`; -} - -function mathJaxRuntime() { - return (window as typeof window & {MathJax?: MathJaxRuntime}).MathJax; -} - -let mathJaxLoad: Promise | null = null; -let mathTypesetChain = Promise.resolve(); -let mathTypesetFrame = 0; -const pendingMathElements = new Map(); - -function ensureMathJax() { - const active = mathJaxRuntime(); - if (active?.typesetPromise) return Promise.resolve(active); - if (mathJaxLoad) return mathJaxLoad; - - (window as typeof window & {MathJax?: unknown}).MathJax = { - loader: {paths: {fonts: `${mathJaxAssetPath}/fonts`}}, - output: {font: "mathjax-newcm"}, - tex: { - inlineMath: {"[+]": [["$", "$"]]}, - processEscapes: true - }, - options: { - enableSpeech: false, - enableBraille: false, - enableExplorer: false, - menuOptions: {settings: {speech: false, braille: false}} - }, - svg: {fontCache: "local"}, - startup: {typeset: false} - }; - - mathJaxLoad = new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = `${mathJaxAssetPath}/tex-svg-nofont.js`; - script.async = true; - script.dataset.mathjax = "true"; - script.addEventListener("error", () => reject(new Error("MathJax 加载失败")), {once: true}); - script.addEventListener("load", async () => { - try { - const runtime = mathJaxRuntime(); - await runtime?.startup?.promise; - if (!runtime?.typesetPromise) throw new Error("MathJax 初始化失败"); - resolve(runtime); - } catch (error) { - reject(error); - } - }, {once: true}); - document.head.appendChild(script); - }).catch((error) => { - mathJaxLoad = null; - throw error; - }); - return mathJaxLoad; -} - -function clearMathTypesetting(element: Element) { - mathJaxRuntime()?.typesetClear?.([element]); -} - -function scheduleMathTypesetting(element: Element) { - const candidates = element.matches('.math-fragment:not([data-math-rendered="1"])') - ? [element as HTMLElement] - : Array.from(element.querySelectorAll('.math-fragment:not([data-math-rendered="1"])')); - for (const candidate of candidates) { - pendingMathElements.set(candidate, candidate.dataset.mathKey || ""); - candidate.dataset.mathPending = "1"; - } - if (!candidates.length || mathTypesetFrame) return; - mathTypesetFrame = window.requestAnimationFrame(() => { - mathTypesetFrame = 0; - const batch = [...pendingMathElements.entries()]; - pendingMathElements.clear(); - mathTypesetChain = mathTypesetChain.then(async () => { - const current = batch.filter(([candidate, mathKey]) => candidate.isConnected && candidate.dataset.mathKey === mathKey); - if (!current.length) return; - const runtime = await ensureMathJax(); - const stillCurrent = current.filter(([candidate, mathKey]) => candidate.isConnected && candidate.dataset.mathKey === mathKey); - if (!stillCurrent.length) return; - const viewport = root.querySelector("#thread-viewport"); - const wasAtBottom = isViewportAtBottom(viewport); - const previousScrollTop = viewport?.scrollTop || 0; - await runtime.typesetPromise!(stillCurrent.map(([candidate]) => candidate)); - markdownRenderMetrics.mathTypesets += stillCurrent.length; - for (const [candidate, mathKey] of stillCurrent) { - if (candidate.isConnected && candidate.dataset.mathKey === mathKey) { - candidate.dataset.mathRendered = "1"; - candidate.dataset.mathTypesetCount = String(Number(candidate.dataset.mathTypesetCount || "0") + 1); - const block = candidate.closest(".markdown-block"); - if (block) block.dataset.mathTypesetCount = String(Number(block.dataset.mathTypesetCount || "0") + 1); - delete candidate.dataset.mathPending; - } - } - if (wasAtBottom) scrollBottom(); - else if (viewport) viewport.scrollTop = previousScrollTop; - for (const [candidate, mathKey] of stillCurrent) { - if (candidate.isConnected && candidate.dataset.mathKey !== mathKey) scheduleMathTypesetting(candidate); - } - }).catch((error) => { - for (const [candidate] of batch) delete candidate.dataset.mathPending; - console.error("MathJax typesetting failed", error); - }); - }); -} - -function estimateFrontendOutputTokens(value: string): number { - const trimmed = value.trim(); - if (!trimmed) return 0; - const withoutSpaces = trimmed.replace(/\s+/g, ""); - const chineseChars = (withoutSpaces.match(/\p{Script=Han}/gu) || []).length; - const nonChineseChars = withoutSpaces.length - chineseChars; - return Math.max(0, Math.round(chineseChars + nonChineseChars / 4)); -} - -function provider() { - return state.config?.providers.find((item) => item.id === state.providerId) || null; -} - -function localCredential(providerId = state.providerId) { - return state.localCredentials.find((item) => item.providerId === providerId && item.name === "default") - || state.localCredentials.find((item) => item.providerId === providerId) - || null; -} - -function updateConversationHash(id: string, mode: Exclude) { - const hash = conversationHash(id); - if (window.location.hash === hash) return; - const url = `${window.location.pathname}${window.location.search}${hash}`; - window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", url); -} - -function settingsForProvider(item: ChatProvider) { - const saved = window.localStorage.getItem(`xiteng-chat-model:${item.id}`) || ""; - const model = item.models.some((candidate) => candidate.id === saved) - ? saved - : item.models.some((candidate) => candidate.id === item.defaultModel) ? item.defaultModel : item.models[0]?.id || ""; - return {model}; -} - -function rememberModel(providerId: string, model: string) { - if (!providerId || !model) return; - const key = `${providerId}/${model}`; - state.recentModelKeys = [key, ...state.recentModelKeys.filter((item) => item !== key)].slice(0, 20); - window.localStorage.setItem("xiteng-chat-recent-models", JSON.stringify(state.recentModelKeys)); -} - -function avatarPlaceholder(profile: ChatProfile) { - const source = String(profile.name || profile.username || "U").trim() || "U"; - const parts = source.split(/\s+/).filter(Boolean); - const initials = (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)![0]}` : [...source].slice(0, 2).join("")).toUpperCase(); - let hash = 0; - for (const character of String(profile.username || profile.name || initials)) hash = ((hash << 5) - hash + character.codePointAt(0)!) | 0; - const hue = Math.abs(hash) % 360; - const svg = `${escapeHtml(initials)}`; - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; -} - -async function updateAvatar() { - const image = root.querySelector(".header-avatar"); - const profile = state.config?.profile; - const email = String(profile?.email || "").trim().toLowerCase(); - if (!image || !email) return; - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(email)); - const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); - for (const source of [`https://www.gravatar.com/avatar/${hash}?d=404&s=256`, `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256`]) { - const loaded = await new Promise((resolve) => { - const candidate = new Image(); - const timer = window.setTimeout(() => resolve(false), 5000); - candidate.onload = () => { window.clearTimeout(timer); resolve(true); }; - candidate.onerror = () => { window.clearTimeout(timer); resolve(false); }; - candidate.referrerPolicy = "no-referrer"; - candidate.src = source; - }); - if (loaded && image.isConnected) { - image.src = source; - break; - } - } -} - -type ModelChoice = {provider: ChatProvider; model: ProviderModel; key: string}; - -function availableModelChoices(): ModelChoice[] { - return state.config?.providers.flatMap((item) => item.models.map((model) => ({provider: item, model, key: `${item.id}/${model.id}`}))) || []; -} - -function renderEffortControl(name: string) { - const options: Array<[ReasoningLevel, string]> = [["auto", "自动"], ["none", "关闭"], ["low", "低"], ["medium", "中"], ["high", "高"]]; - return `
Effort控制模型的思考强度
${options.map(([value, label]) => ``).join("")}
`; -} - -function renderModelOption(choice: ModelChoice) { - const active = choice.provider.id === state.providerId && choice.model.id === state.model; - const displayName = compactModelName(choice.model.name || choice.model.id); - const detail = displayName === choice.model.id - ? choice.provider.name - : `${choice.model.id} · ${choice.provider.name}`; - return ``; -} - -function quickModelChoices(choices: ModelChoice[]) { - const activeKey = `${state.providerId}/${state.model}`; - const preferredKeys = [activeKey, ...state.recentModelKeys, ...(state.config?.providers.map((item) => `${item.id}/${item.defaultModel}`) || [])]; - const selected: ModelChoice[] = []; - for (const key of preferredKeys) { - const choice = choices.find((item) => item.key === key); - if (choice && !selected.includes(choice)) selected.push(choice); - } - for (const choice of choices) if (selected.length < 6 && !selected.includes(choice)) selected.push(choice); - return selected.slice(0, 6); -} - -function renderModelPicker() { - const active = provider(); - if (!active || !state.config) return ""; - const activeModelName = compactModelName(active.models.find((model) => model.id === state.model)?.name || state.model); - const choices = quickModelChoices(availableModelChoices()); - return `
${escapeHtml(activeModelName)}${icons.down}
模型当前与最近使用
${choices.map(renderModelOption).join("")}
${renderEffortControl("quick-effort")}
`; -} - -function renderProviderSettings() { - if (!state.frontendProviders.length) return ""; - return `
Provider 连接找不到模型时,在这里配置浏览器直连 Provider
${state.frontendProviders.map((item) => { - const configured = state.localCredentials.some((credential) => credential.providerId === item.id); - return `
${escapeHtml(item.name)}${escapeHtml(item.auth.type === "none" ? item.connection.baseUrl : item.id)}${item.modelDiscoveryError ? `${escapeHtml(item.modelDiscoveryError)}` : ""}
${configured ? `` : ""}${item.auth.type === "none" ? `` : ""}
`; - }).join("")}
`; -} - -function renderGenerationSettings() { - const settings = state.generationSettings; - return `${renderEffortControl("settings-effort")}
`; -} - -function renderSettingsPage() { - if (!state.settingsOpen || !state.config) return ""; - const query = state.modelQuery.trim().toLowerCase(); - const choices = availableModelChoices(); - const matches = choices.filter((choice) => !query || choice.key.toLowerCase().includes(query) || choice.model.name.toLowerCase().includes(query) || choice.provider.name.toLowerCase().includes(query)); - const groups = state.config.providers.map((item) => { - const items = matches.filter((choice) => choice.provider.id === item.id); - return items.length ? `

${escapeHtml(item.name)}

${items.map(renderModelOption).join("")}
` : ""; - }).join(""); - const providerSettings = renderProviderSettings(); - const accountHref = state.authenticated ? "https://xiteng.site/account" : appUrl("/api/login"); - const accountLabel = state.authenticated ? "管理 Backend Provider 与凭据" : "登录并配置 Backend Provider"; - return ``; -} - -function scrollSettingsSection(id: string) { - const section = root.querySelector(`#${CSS.escape(id)}`); - section?.scrollIntoView({behavior: "smooth", block: "start"}); -} - -function historyItem(item: ConversationSummary, label = item.name, depth = 0) { - const displayLabel = String(label || "").trim() || untitledConversationLabel; - return `
`; -} - -type HistoryTreeNode = {segment: string; path: string; conversations: ConversationSummary[]; children: Map}; - -function renderHistoryItems() { - if (!state.historyTree) return state.conversations.map((item) => historyItem(item)).join(""); - const rootNode: HistoryTreeNode = {segment: "", path: "", conversations: [], children: new Map()}; - for (const conversation of state.conversations) { - const segments = String(conversation.name || "").split("/").filter(Boolean); - let node = rootNode; - segments.forEach((segment, index) => { - const path = segments.slice(0, index + 1).join("/"); - let child = node.children.get(segment); - if (!child) { - child = {segment, path, conversations: [], children: new Map()}; - node.children.set(segment, child); - } - node = child; - }); - node.conversations.push(conversation); - } - const renderNode = (node: HistoryTreeNode, depth: number): string => { - const children = [...node.children.values()].sort((left, right) => left.segment.localeCompare(right.segment)); - const hasChildren = children.length > 0; - const rows = node.conversations.map((item) => historyItem(item, node.segment || item.name, depth)).join(""); - const folder = hasChildren && node.segment - ? `
${escapeHtml(node.segment)}
` - : ""; - return `${folder}${rows}${children.map((child) => renderNode(child, node.segment ? depth + 1 : depth)).join("")}`; - }; - return renderNode(rootNode, 0); -} - -function importTitleTemplatePreview() { - try { - return { - text: `预览:${applyImportTitleTemplate(state.importTitleTemplate, {title: "修复登录问题", format: "codex", file: "rollout-123", folder: "sessions", date: "2026-08-13", model: "gpt-5.6-sol", provider: "openai", index: 1})}`, - error: false - }; - } catch (error) { - return {text: error instanceof Error ? error.message : "标题模板无效", error: true}; - } -} - -function renderImportPanel() { - if (!state.importPanelOpen) return ""; - const disabled = state.importing ? " disabled" : ""; - const preview = importTitleTemplatePreview(); - return ``; -} - -function renderHistory() { - const transferMenu = `
${icons.transfer}

`; - return ``; -} - -function activeReplyTargetId() { - const draft = activeDraft(); - if (!draft || draft.editSourceMessageId) return undefined; - const latestId = displayedMessages().at(-1)?.id || null; - return draft.observedHeadId === latestId ? undefined : draft.observedHeadId; -} - -function replyAction(message: StoredChatMessage, index: number) { - return ``; -} - -function renderMessage(message: StoredChatMessage, index: number) { - const replyTarget = activeReplyTargetId() === message.id; - const messageAttributes = `data-message-index="${index}" data-message-id="${escapeHtml(message.id)}"`; - const branches = renderBranchNavigator(message); - if (message.role === "user") { - return `

${escapeHtml(messagePartText(message, "text"))}

${replyAction(message, index)}${branches}
`; - } - if (message.role !== "assistant") return ""; - const reasoning = messagePartText(message, "reasoning"); - const text = messagePartText(message, "text"); - const streamed = isStreamingAssistant(message, index); - const renderedText = text ? renderMarkdownContainer(text, message.id, !streamed || finishingMarkdownMessages.has(message.id)) : null; - const error = message.parts.find((part) => part.type === "error" && typeof part.text === "string")?.text; - const response = message.metadata?.custom?.response; - const modelLabel = response?.model ? `${response.providerId || state.providerId}/${response.model}` : `${state.providerId}/${state.model}`; - const speed = typeof response?.tokensPerSecond === "number" ? `${response.tokensPerSecond.toFixed(1)} tok/s` : "速度 —"; - const detail = response?.durationMs ? `${(response.durationMs / 1000).toFixed(1)} 秒${typeof response.outputTokens === "number" ? ` · ${response.outputTokens} tokens` : ""}` : "历史回复未记录速度"; - const partial = message.completion.status === "partial" ? `未完成${message.completion.reason ? ` · ${escapeHtml(message.completion.reason)}` : ""}` : ""; - return `
${reasoning ? `
思考过程
${escapeHtml(reasoning)}
` : ""}${renderedText || state.streaming && state.conversation?.messages.at(-1)?.id === message.id ? renderedText || '' : ""}${error ? `
${escapeHtml(error)}
` : ""}
`; -} - -function renderBranchNavigator(message: StoredChatMessage) { - const siblings = message.parentMessageId === null - ? rootEditAlternativesInGraph(knownMessageMap(), message.id) - : messageChildren(message.parentMessageId); - if (siblings.length < 2) return ""; - const index = siblings.findIndex((candidate) => candidate.id === message.id); - if (index < 0) return ""; - const previous = siblings[(index - 1 + siblings.length) % siblings.length]; - const next = siblings[(index + 1) % siblings.length]; - return `${index + 1} / ${siblings.length}`; -} - -function renderMessagesMarkup() { - const messages = displayedMessages(); - if (!messages.length) { - const description = !state.authenticated - ? "Frontend Provider 由当前浏览器直连;对话与草稿保存在本机,登录后可启用个人同步仓库。" - : provider()?.connection.type === "frontend" - ? "Frontend Provider 由当前浏览器直连;对话记录按 Authentik 身份同步。" - : "Backend Provider 由 Chat 服务端直连;对话记录按 Authentik 身份保存在服务端。"; - return `
XT

今天想聊什么?

${escapeHtml(description)}

`; - } - return messages.map(renderMessage).join(""); -} - -function activeDraft() { - return state.workingItems.find((item) => item.id === state.activeDraftId && item.kind === "user-draft") || null; -} - -function draftLabel(item: WorkingItem) { - return item.editSourceMessageId ? "编辑草稿" : "草稿"; -} - -function workingItemText(item: WorkingItem) { - return item.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join(""); -} - -function renderWorkingPanel() { - const drafts = state.workingItems.filter((item) => item.kind === "user-draft"); - const unfinished = state.workingItems.filter((item) => item.kind === "assistant-stream" && item.status !== "streaming"); - const requestAssistantReply = activeDraft()?.requestAssistantReply ?? true; - const assistantReplyToggle = state.advancedActions - ? `` - : ""; - const draftRows = drafts.map((item) => { - const text = workingItemText(item).trim().replace(/\s+/g, " "); - return `
`; - }).join(""); - const unfinishedRows = unfinished.map((item) => { - const text = workingItemText(item).trim().replace(/\s+/g, " "); - return `
未完成回答${escapeHtml(text.slice(0, 64) || "尚未输出正文")} · ${new Date(item.updatedAt).toLocaleString()}
`; - }).join(""); - return `
${assistantReplyToggle}${renderComposerControls(activeDraft())}${unfinishedRows ? `
未完成 ${unfinished.length}
${unfinishedRows}
` : ""}
草稿 ${drafts.length}
${draftRows}
`; -} - -function replyTargetLabel(message: StoredChatMessage) { - const role = message.role === "assistant" ? "助手" : message.role === "user" ? "用户" : "系统"; - const text = messagePartText(message, "text").replace(/\s+/g, " ").trim(); - const partial = message.completion.status === "partial" ? " · 未完成" : ""; - return `${role}${partial} · ${text.slice(0, 48) || "空消息"}`; -} - -function renderComposerControls(draft: WorkingItem | null) { - const messages = displayedMessages(); - const targetId = draft ? draft.observedHeadId : messages.at(-1)?.id || null; - const target = targetId ? knownMessageMap().get(targetId) : null; - const latestId = messages.at(-1)?.id || null; - const showReplyContext = Boolean(draft && !draft.editSourceMessageId && targetId !== latestId); - const replyContext = showReplyContext - ? `
` - : ""; - const incompleteControl = target?.completion.status === "partial" - ? `` - : ""; - return `
${replyContext}${incompleteControl}
`; -} - -function updateComposerControls() { - const controls = root.querySelector(".composer-controls"); - if (controls) controls.outerHTML = renderComposerControls(activeDraft()); -} - -function renderBranchPreviewNotice() { - if (!state.previewHeadId || state.previewHeadId === state.conversation?.headMessageId) return ""; - return `
正在查看非当前分支
`; -} - -function renderThread() { - const draft = activeDraft(); - const editing = Boolean(draft?.editSourceMessageId); - const editRole = draft?.messageRole === "assistant" ? "助手回答" : "用户消息"; - const fullscreen = state.composerFullscreen; - const queued = draft?.id === state.queuedDraftId; - const note = queued ? "已排队;会在当前回答完成后提交。" : state.offline ? "离线模式:提交保存在本地仓库,联网后自动 push。" : "草稿自动保存在此浏览器;模型可能会出错。"; - const editorLabel = editing ? `正在编辑${editRole}` : "全屏编辑"; - const fullscreenHeader = fullscreen - ? `
${editorLabel}草稿自动保存在此浏览器
` - : ""; - const placeholder = fullscreen - ? editing ? "编辑消息;Ctrl/⌘ + Enter 提交" : "输入消息;Ctrl/⌘ + Enter 提交" - : editing ? "编辑消息,提交后从此处继续" : "输入消息,Enter 发送,Shift + Enter 换行"; - return `
${renderBranchPreviewNotice()}
${renderMessagesMarkup()}
${fullscreenHeader}${renderWorkingPanel()}${editing ? `
正在编辑${editRole};提交后当前会话将从这里继续
` : ""}
${state.streaming ? `` : ""}
${renderModelPicker()}

${note}

`; -} - -function setComposerFullscreen(fullscreen: boolean) { - const currentInput = root.querySelector('textarea[name="message"]'); - const selectionStart = currentInput?.selectionStart ?? 0; - const selectionEnd = currentInput?.selectionEnd ?? selectionStart; - state.composerFullscreen = fullscreen; - renderApp(); - window.requestAnimationFrame(() => { - const nextInput = root.querySelector('textarea[name="message"]'); - if (nextInput && !fullscreen) { - nextInput.style.height = "auto"; - nextInput.style.height = `${Math.min(nextInput.scrollHeight, 180)}px`; - } - nextInput?.focus(); - nextInput?.setSelectionRange(selectionStart, selectionEnd); - }); -} - -function syncIndicatorTitle() { - if (!state.authenticated) return state.offline - ? "当前离线;数据安全保存在当前浏览器,联网后可登录并启用个人同步仓库" - : "本地模式:数据仅保存在当前浏览器;登录后可使用个人同步仓库"; - const last = state.lastFetchAt ? new Date(state.lastFetchAt).toLocaleString() : "从未完成 fetch"; - if (state.offline) return `当前离线;本地更改安全保存在浏览器中 · 上次完成:${last}`; - if (state.syncing) return `正在 fetch · 上次完成:${last}`; - if (state.syncError) return `本地更改安全保存在浏览器中 · ${state.syncError} · 上次完成:${last}`; - if (!state.initialFetchComplete) return `尚未完成首次 fetch · 上次完成:${last}`; - return `上次 fetch:${last}`; -} - -function syncIndicatorState() { - if (!state.authenticated) return {className: state.offline ? "offline" : "local", label: state.offline ? "离线 · 登录" : "本地 · 登录"}; - if (state.offline) return {className: "offline", label: "离线"}; - if (state.syncing) return {className: "fetching", label: "同步中"}; - if (state.syncError) return {className: "error", label: "待同步"}; - if (!state.initialFetchComplete) return {className: "fetching", label: "未 fetch"}; - return {className: "synced", label: "已同步"}; -} - -function updateSyncIndicator() { - const indicator = root.querySelector(".identity-sync-control"); - if (!indicator) return; - const visual = syncIndicatorState(); - indicator.className = `identity-sync-control ${visual.className}`; - indicator.title = syncIndicatorTitle(); - indicator.setAttribute("aria-label", state.authenticated ? `打开我的账户;${visual.label}` : `${visual.label};启用个人同步`); - const label = indicator.querySelector(".identity-sync-label"); - if (label) label.textContent = visual.label; -} - -function renderIdentitySyncControl(profile: ChatProfile) { - const visual = syncIndicatorState(); - const title = syncIndicatorTitle(); - const href = state.authenticated ? "https://xiteng.site/account" : appUrl("/api/login"); - const ariaLabel = state.authenticated ? `打开我的账户;${visual.label}` : `${visual.label};启用个人同步`; - const identity = state.authenticated - ? `${escapeHtml(profile.name || profile.username)} 的头像` - : ``; - return `${identity}${escapeHtml(visual.label)}`; -} - -function renderApp() { - clearMathTypesetting(root); - if (state.error) { - root.innerHTML = `
!

聊天服务暂时不可用

${escapeHtml(state.error)}

${renderProviderSettings()}
`; - return; - } - if (state.loading || !state.config || !provider() || !state.conversation) { - root.innerHTML = '

正在读取聊天历史与 Provider Registry…

'; - return; - } - const profile = state.config.profile; - root.innerHTML = `
${renderIdentitySyncControl(profile)}
${renderHistory()}${renderThread()}${renderImportPanel()}${renderSettingsPage()}
`; - scheduleMathTypesetting(root); - void updateAvatar(); -} - -function renderMessages(scroll = false) { - const list = root.querySelector("#message-list"); - if (!list) { - renderApp(); - return; - } - const viewport = root.querySelector("#thread-viewport"); - const wasAtBottom = isViewportAtBottom(viewport); - const previousScrollTop = viewport?.scrollTop || 0; - const reasoningOpenStates = captureReasoningOpenStates(list); - const messages = displayedMessages(); - if (!messages.length) { - clearMathTypesetting(list); - list.innerHTML = renderMessagesMarkup(); - } else if (list.querySelector(".welcome")) { - clearMathTypesetting(list); - list.innerHTML = renderMessagesMarkup(); - scheduleMathTypesetting(list); - } else { - const visibleMessages = messages - .map((message, index) => ({message, index})) - .filter(({message}) => message.role === "user" || message.role === "assistant"); - const renderedMessages = Array.from(list.querySelectorAll(":scope > article[data-message-id]")); - const prefixMatches = renderedMessages.every((node, renderedIndex) => node.dataset.messageId === visibleMessages[renderedIndex]?.message.id); - if (!prefixMatches || renderedMessages.length > visibleMessages.length) { - clearMathTypesetting(list); - list.innerHTML = renderMessagesMarkup(); - scheduleMathTypesetting(list); - } else { - for (let renderedIndex = renderedMessages.length; renderedIndex < visibleMessages.length; renderedIndex += 1) { - const {message, index} = visibleMessages[renderedIndex]; - const node = renderedMessageNode(message, index); - if (node) { - list.appendChild(node); - scheduleMathTypesetting(node); - } - } - const last = visibleMessages.at(-1); - const existingLast = list.lastElementChild; - if (last?.message.role === "assistant" && existingLast instanceof HTMLElement && existingLast.dataset.messageId === last.message.id) { - patchAssistantMessage(existingLast, last.message, last.index); - } - } - } - restoreReasoningOpenStates(list, reasoningOpenStates); - updateStreamingControls(); - if (scroll) { - if (wasAtBottom) scrollBottom(); - else if (viewport) viewport.scrollTop = previousScrollTop; - } -} - -function isViewportAtBottom(viewport: HTMLElement | null): boolean { - if (!viewport) return false; - return viewport.scrollHeight - viewport.clientHeight <= viewport.scrollTop + 8; -} - -function renderedMessageNode(message: StoredChatMessage, index: number): HTMLElement | null { - const template = document.createElement("template"); - template.innerHTML = renderMessage(message, index); - const node = template.content.firstElementChild; - return node instanceof HTMLElement ? node : null; -} - -function patchMathBlock(current: HTMLElement, desired: HTMLElement) { - const currentFragments = new Map( - Array.from(current.querySelectorAll(".math-fragment[data-math-key]")) - .map((fragment) => [fragment.dataset.mathKey || "", fragment] as const) - ); - const next = desired.cloneNode(true) as HTMLElement; - for (const placeholder of next.querySelectorAll(".math-fragment[data-math-key]")) { - const key = placeholder.dataset.mathKey || ""; - const preserved = currentFragments.get(key); - if (!preserved) continue; - placeholder.replaceWith(preserved); - currentFragments.delete(key); - } - for (const obsolete of currentFragments.values()) clearMathTypesetting(obsolete); - current.replaceChildren(...Array.from(next.childNodes)); - current.dataset.renderKey = desired.dataset.renderKey; - current.dataset.hasMath = desired.dataset.hasMath; - current.dataset.blockStable = desired.dataset.blockStable; - current.dataset.blockType = desired.dataset.blockType; - scheduleMathTypesetting(current); -} - -function patchMarkdownContainer(current: HTMLElement, desired: HTMLElement) { - const currentBlocks = Array.from(current.querySelectorAll(":scope > .markdown-block")); - const desiredBlocks = Array.from(desired.querySelectorAll(":scope > .markdown-block")); - const prefixMatches = currentBlocks.every((block, index) => block.dataset.blockId === desiredBlocks[index]?.dataset.blockId); - if (!prefixMatches || currentBlocks.length > desiredBlocks.length) { - clearMathTypesetting(current); - current.replaceChildren(...desiredBlocks.map((block) => block.cloneNode(true))); - current.dataset.renderKey = desired.dataset.renderKey; - markdownRenderMetrics.domBlocksUpdated += desiredBlocks.length; - scheduleMathTypesetting(current); - return; - } - - for (let index = 0; index < desiredBlocks.length; index += 1) { - const desiredBlock = desiredBlocks[index]; - const currentBlock = currentBlocks[index]; - if (!currentBlock) { - const appended = desiredBlock.cloneNode(true) as HTMLElement; - current.appendChild(appended); - markdownRenderMetrics.domBlocksUpdated += 1; - scheduleMathTypesetting(appended); - continue; - } - if (currentBlock.dataset.renderKey === desiredBlock.dataset.renderKey) { - currentBlock.dataset.blockStable = desiredBlock.dataset.blockStable; - continue; - } - markdownRenderMetrics.domBlocksUpdated += 1; - if (desiredBlock.dataset.hasMath === "1") { - patchMathBlock(currentBlock, desiredBlock); - } else { - clearMathTypesetting(currentBlock); - currentBlock.innerHTML = desiredBlock.innerHTML; - currentBlock.dataset.renderKey = desiredBlock.dataset.renderKey; - currentBlock.dataset.hasMath = desiredBlock.dataset.hasMath; - currentBlock.dataset.blockStable = desiredBlock.dataset.blockStable; - currentBlock.dataset.blockType = desiredBlock.dataset.blockType; - delete currentBlock.dataset.mathRendered; - } - } - current.dataset.renderKey = desired.dataset.renderKey; -} - -function patchAssistantMessage(existing: HTMLElement, message: StoredChatMessage, index: number) { - const next = renderedMessageNode(message, index); - if (!next) return; - const existingContent = existing.querySelector(".assistant-content"); - const nextContent = next.querySelector(".assistant-content"); - if (!existingContent || !nextContent) return; - - const selectors = [".partial-badge", ".message-reasoning", ".aui-md", ".response-loader", ".message-error"]; - const retained = new Set(); - const changedMathElements = new Set(); - let insertionPoint = existingContent.firstElementChild; - for (const selector of selectors) { - const desired = nextContent.querySelector(`:scope > ${selector}`); - let current = existingContent.querySelector(`:scope > ${selector}`); - if (!desired) { - if (current) { - if (selector === ".aui-md") clearMathTypesetting(current); - if (current === insertionPoint) insertionPoint = current.nextElementSibling; - current.remove(); - } - continue; - } - if (!current) { - current = desired.cloneNode(true) as HTMLElement; - if (selector === ".aui-md") changedMathElements.add(current); - } else if (selector === ".message-reasoning") { - const currentBody = current.querySelector(":scope > div"); - const desiredBody = desired.querySelector(":scope > div"); - if (currentBody && desiredBody && currentBody.textContent !== desiredBody.textContent) currentBody.textContent = desiredBody.textContent; - } else if (selector === ".aui-md" && current.dataset.renderKey !== desired.dataset.renderKey) { - patchMarkdownContainer(current, desired); - } else if (selector === ".message-error" && current.textContent !== desired.textContent) { - current.textContent = desired.textContent; - } - if (current !== insertionPoint) existingContent.insertBefore(current, insertionPoint); - insertionPoint = current.nextElementSibling; - retained.add(current); - } - for (const child of Array.from(existingContent.children)) { - if (!retained.has(child)) child.remove(); - } - - const existingFooter = existing.querySelector(".message-footer"); - const nextFooter = next.querySelector(".message-footer"); - if (existingFooter && nextFooter && existingFooter.innerHTML !== nextFooter.innerHTML) existingFooter.innerHTML = nextFooter.innerHTML; - for (const element of changedMathElements) scheduleMathTypesetting(element); -} - -function updateStreamingControls() { - const composer = root.querySelector("#composer"); - const composerActions = composer?.querySelector(".composer-actions") || null; - const button = root.querySelector(".send-button"); - if (button) { - button.type = "submit"; - button.dataset.action = "send"; - button.setAttribute("aria-label", state.streaming ? "排队发送" : "发送消息"); - button.disabled = false; - if (button.innerHTML !== icons.send) button.innerHTML = icons.send; - } - let stopButton = composer?.querySelector(".stop-button") || null; - if (state.streaming && composer && composerActions && !stopButton) { - composerActions.insertAdjacentHTML("afterbegin", ``); - stopButton = composer.querySelector(".stop-button"); - } - if (!state.streaming) stopButton?.remove(); - root.querySelectorAll('[data-action="regenerate-message"]').forEach((button) => { - button.disabled = state.streaming; - }); - root.querySelectorAll('[data-action="edit-message"]').forEach((button) => { - button.disabled = state.streaming; - }); -} - -function captureReasoningOpenStates(list: HTMLElement) { - const states = new Map(); - list.querySelectorAll("article[data-message-id] details.message-reasoning").forEach((details) => { - const article = details.closest("article[data-message-id]"); - if (!article) return; - const id = article.dataset.messageId || ""; - states.set(id, details.hasAttribute("open")); - }); - return states; -} - -function restoreReasoningOpenStates(list: HTMLElement, states: Map) { - list.querySelectorAll("article[data-message-id] details.message-reasoning").forEach((details) => { - const article = details.closest("article[data-message-id]"); - if (!article) return; - const id = article.dataset.messageId || ""; - const open = states.get(id); - if (open === undefined) return; - if (open) details.setAttribute("open", "open"); - else details.removeAttribute("open"); - }); -} - -function scheduleMessagesRender(scroll = true) { - if (state.renderFrame) return; - state.renderFrame = window.requestAnimationFrame(() => { - state.renderFrame = 0; - renderMessages(scroll); - }); -} - -function scrollBottom(behavior: ScrollBehavior = "auto") { - const viewport = root.querySelector("#thread-viewport"); - if (viewport) viewport.scrollTo({top: viewport.scrollHeight, behavior}); -} - -function closeHistoryOnMobile() { - if (window.matchMedia("(max-width: 680px)").matches) state.historyOpen = false; -} - -function providerHeaders(item: ChatProvider, secret: ProviderSecret, initial?: HeadersInit) { - const headers = new Headers(initial); - for (const [name, value] of Object.entries(item.headers || {})) headers.set(name, value); - for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value); - const apiKey = secret.provider?.apiKey || ""; - if (item.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`); - if (item.auth.type === "header" && item.auth.header && apiKey) headers.set(item.auth.header, apiKey); - return headers; -} - -function normalizeModels(payload: unknown): ProviderModel[] { - const root = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record : {}; - const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : []; - return source.slice(0, 300).flatMap((value) => { - if (!value || typeof value !== "object" || Array.isArray(value)) return []; - const model = value as Record; - const rawId = model.id || model.name || model.model; - if (typeof rawId !== "string" || !rawId.trim()) return []; - const id = rawId.replace(/^models\//, ""); - return [{id, name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id}]; - }); -} - -async function discoverFrontendProvider(item: ChatProvider, secret: ProviderSecret) { - const effective = applyBrowserProviderSettings(item, secret) as ChatProvider; - const providerFetch = createBrowserProviderFetch(effective, secret); - const response = await providerFetch(effective.discovery.url, { - headers: providerHeaders(effective, secret, {"Accept": "application/json"}), - signal: AbortSignal.timeout(15000) - }); - const payload = await response.json().catch(() => null); - let models = response.ok ? normalizeModels(payload) : []; - if (effective.id === "llama.cpp" && !models.length) { - const propsUrl = `${effective.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`; - const props = await providerFetch(propsUrl, {headers: providerHeaders(effective, secret), signal: AbortSignal.timeout(5000)}); - const value = await props.json() as {model_alias?: string; model_path?: string}; - const id = value.model_alias?.trim() || value.model_path?.split(/[\\/]/).filter(Boolean).at(-1) || effective.defaultModel; - if (props.ok) models = [{id, name: id}]; - } - if (!response.ok && !models.length) throw new Error(`Provider HTTP ${response.status}`); - return {...effective, models, modelDiscoveryError: undefined}; -} - -async function parseLines(response: Response, onLine: (line: string) => void | Promise) { - if (!response.ok) { - const payload = await response.json().catch(() => null) as {error?: string} | null; - throw new Error(payload?.error || `HTTP ${response.status}`); - } - if (!response.body) throw new Error("Streaming response body is unavailable"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const {done, value} = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), {stream: !done}); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) if (line.trim()) await onLine(line.trim()); - if (done) break; - } - if (buffer.trim()) await onLine(buffer.trim()); -} - -async function streamBackend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal, context?: StreamRequestContext) { - const item = context?.provider || provider()!; - const credential = item.credentials.find((value) => value.name === "default") || item.credentials[0]; - if (!credential) throw new Error(`请先在 Key Vault 中配置 ${item.name}`); - const response = await fetch(appUrl("/api/chat"), { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({conversationId: context?.conversationId || state.conversation!.id, providerId: item.id, credentialName: credential.name, model: context?.model || state.model, generationSettings: context?.generationSettings || state.generationSettings, messages}), - signal - }); - await parseLines(response, (line) => onEvent(JSON.parse(line) as StreamEvent)); -} - -async function streamFrontend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal, context?: StreamRequestContext) { - const item = context?.provider || provider()!; - if (item.api !== "openai-completions") throw new Error(`Frontend Provider 暂不支持 ${item.api}`); - const credential = localCredential(item.id); - const secret = credential?.secret || {}; - const effective = applyBrowserProviderSettings(item, secret) as ChatProvider; - const providerFetch = createBrowserProviderFetch(effective, secret); - const startedAt = performance.now(); - let outputTokens: number | null = null; - let responseText = ""; - const response = await providerFetch(`${effective.connection.baseUrl.replace(/\/+$/, "")}/chat/completions`, { - method: "POST", - headers: providerHeaders(effective, secret, {"Content-Type": "application/json", "Accept": "text/event-stream"}), - body: JSON.stringify({ - model: context?.model || state.model, - messages: messages.filter((message) => message.role !== "system" || messagePartText(message, "text")).map((message) => ({role: message.role, content: messagePartText(message, "text")})), - stream: true, - stream_options: {include_usage: true}, - ...((context?.generationSettings || state.generationSettings).temperature !== null ? {temperature: (context?.generationSettings || state.generationSettings).temperature} : {}), - ...((context?.generationSettings || state.generationSettings).maxOutputTokens !== null ? {max_tokens: (context?.generationSettings || state.generationSettings).maxOutputTokens} : {}), - ...(!["auto", "none"].includes((context?.generationSettings || state.generationSettings).reasoning) ? {reasoning_effort: (context?.generationSettings || state.generationSettings).reasoning} : {}) - }), - signal - }); - await parseLines(response, (line) => { - if (!line.startsWith("data:")) return; - const data = line.slice(5).trim(); - if (!data || data === "[DONE]") return; - const payload = JSON.parse(data) as {choices?: Array<{delta?: {content?: string; reasoning?: string; reasoning_content?: string}}>; usage?: {completion_tokens?: number; output_tokens?: number}}; - const delta = payload.choices?.[0]?.delta; - if (delta?.reasoning_content || delta?.reasoning) onEvent({type: "reasoning-delta", text: delta.reasoning_content || delta.reasoning}); - if (delta?.content) { - responseText += delta.content; - onEvent({type: "text-delta", text: delta.content}); - } - const reported = payload.usage?.completion_tokens ?? payload.usage?.output_tokens; - if (typeof reported === "number") outputTokens = reported; - }); - onEvent({type: "finish", metadata: responseMetadata(item.id, context?.model || state.model, startedAt, outputTokens, outputTokens === null ? estimateFrontendOutputTokens(responseText) : undefined)}); -} - -async function generateConversationTitle(conversation: Conversation, item: ChatProvider, model: string) { - if (titleGenerationConversationIds.has(conversation.id)) return; - titleGenerationConversationIds.add(conversation.id); - try { - const timestamp = messageNow(); - const promptMessage: StoredChatMessage = { - id: uuid(), - parentMessageId: null, - role: "user", - parts: [{type: "text", text: conversationTitlePrompt(conversation.messages)}], - origin: {type: "system", source: "conversation-title"}, - completion: {status: "complete"}, - createdAt: timestamp, - completedAt: timestamp - }; - const generationSettings: GenerationSettings = {reasoning: "none", showReasoningSummary: false, temperature: 0.2, maxOutputTokens: 48}; - const context: StreamRequestContext = {provider: item, model, conversationId: conversation.id, generationSettings}; - let output = ""; - const onEvent = (event: StreamEvent) => { - if (event.type === "text-delta" && event.text) output += event.text; - if (event.type === "error") throw new Error(event.error || "标题生成失败"); - }; - const signal = AbortSignal.timeout(30000); - if (item.connection.type === "backend") await streamBackend([promptMessage], onEvent, signal, context); - else await streamFrontend([promptMessage], onEvent, signal, context); - const generated = normalizeGeneratedConversationTitle(output); - if (!generated) return; - const current = await getConversationHistory(conversation.id); - if (current.name) return; - const name = nextAvailableConversationName(generated); - const updated = await updateConversationHistory(current.id, current.providerId, current.model, current.generationSettings, name); - if (state.conversation?.id === updated.id) state.conversation = updated; - await refreshConversations(); - const historyList = root.querySelector(".history-list"); - if (historyList) historyList.innerHTML = renderHistoryItems(); - scheduleRepositorySync(); - } catch (error) { - console.warn("Unable to generate conversation title", error); - } finally { - titleGenerationConversationIds.delete(conversation.id); - } -} - -async function refreshConversations() { - state.conversations = await listConversationHistory(); -} - -function scheduleRepositorySync(delay = 50) { - if (!state.authenticated) return; - state.syncRequested = true; - window.clearTimeout(state.syncTimer); - state.syncTimer = window.setTimeout(() => void synchronizeRepository(), delay); -} - -async function synchronizeRepository() { - if (!state.authenticated) return; - if (state.syncing || !navigator.onLine) { - if (!navigator.onLine) { - state.offline = true; - state.syncError = "当前离线,等待下次 fetch"; - updateSyncIndicator(); - } - return; - } - state.syncRequested = false; - state.syncing = true; - state.syncError = ""; - updateSyncIndicator(); - try { - const result = await synchronizeConversationRepository(); - state.lastFetchAt = result.fetchedAt; - state.initialFetchComplete = true; - state.offline = false; - state.syncError = result.conflicts ? `${result.conflicts} 个会话发生分叉,本地 head 已保留` : ""; - state.conversations = result.summaries; - state.messageGraph = await listCachedMessages(); - if (!state.streaming && state.conversation && state.conversations.some((item) => item.id === state.conversation!.id)) { - state.conversation = await getConversationHistory(state.conversation.id); - renderMessages(true); - const historyList = root.querySelector(".history-list"); - if (historyList) historyList.innerHTML = renderHistoryItems(); - } - } catch (error) { - state.offline = !navigator.onLine; - state.syncError = error instanceof Error ? error.message : "Fetch failed"; - } finally { - state.syncing = false; - updateSyncIndicator(); - if (state.syncRequested) scheduleRepositorySync(); - } -} - -function messageNow() { - return new Date().toISOString(); -} - -async function immutableMessage(input: Pick & {metadata?: StoredChatMessage["metadata"]}) { - const timestamp = messageNow(); - return createMessageObject({...input, createdAt: timestamp, completedAt: timestamp}, state.identityKey); -} - -async function loadConversationWorkingItems(conversationId: string) { - state.messageGraph = await listCachedMessages(); - state.workingItems = await listWorkingItems(conversationId); - for (const item of state.workingItems) { - if (item.kind === "assistant-stream" && item.status === "streaming") { - item.status = "interrupted"; - item.failureReason = "connection-lost"; - await saveWorkingItem(item); - } - } - const drafts = state.workingItems.filter((item) => item.kind === "user-draft"); - if (!drafts.some((item) => item.id === state.activeDraftId)) state.activeDraftId = drafts[0]?.id || ""; -} - -async function persistWorkingItem(item: WorkingItem, render = false) { - item.updatedAt = messageNow(); - await saveWorkingItem(item); - const index = state.workingItems.findIndex((candidate) => candidate.id === item.id); - if (index >= 0) state.workingItems[index] = item; - else state.workingItems.unshift(item); - if (render) renderApp(); -} - -function newDraftItem(conversationId: string, options: {observedHeadId?: string | null; editSourceMessageId?: string; text?: string; messageRole?: "user" | "assistant"; requestAssistantReply?: boolean} = {}): WorkingItem { - const timestamp = messageNow(); - return { - id: uuid(), - conversationId, - kind: "user-draft", - observedHeadId: options.observedHeadId === undefined ? displayedMessages().at(-1)?.id || null : options.observedHeadId, - ...(options.editSourceMessageId ? {editSourceMessageId: options.editSourceMessageId} : {}), - messageRole: options.messageRole || "user", - requestAssistantReply: options.requestAssistantReply ?? true, - incompleteTargetAction: "append", - parts: options.text ? [{type: "text", text: options.text}] : [], - status: "editing", - createdAt: timestamp, - updatedAt: timestamp - }; -} - -async function editMessage(index: number) { - if (state.streaming || !state.conversation) return; - const message = displayedMessages()[index]; - if (!message || (message.role !== "user" && message.role !== "assistant")) return; - if (message.role === "assistant" && !state.advancedActions) return; - const existing = state.workingItems.find((item) => item.kind === "user-draft" && item.editSourceMessageId === message.id); - const draft = existing || newDraftItem(state.conversation.id, { - observedHeadId: message.parentMessageId, - editSourceMessageId: message.id, - text: messagePartText(message, "text"), - messageRole: message.role, - requestAssistantReply: message.role !== "assistant" - }); - state.activeDraftId = draft.id; - state.composerFullscreen = shouldOpenFullscreenEditor(workingItemText(draft)); - await persistWorkingItem(draft, true); - const input = root.querySelector('textarea[name="message"]'); - if (input && !state.composerFullscreen) { - input.style.height = "auto"; - input.style.height = `${Math.min(input.scrollHeight, 180)}px`; - } - input?.focus(); - input?.setSelectionRange(input.value.length, input.value.length); -} - -async function replyToMessage(index: number) { - if (!state.conversation) return; - const message = displayedMessages()[index]; - if (!message) return; - let draft = activeDraft(); - if (!draft || draft.editSourceMessageId) draft = newDraftItem(state.conversation.id); - draft.observedHeadId = message.id; - state.activeDraftId = draft.id; - await persistWorkingItem(draft, true); - root.querySelector('textarea[name="message"]')?.focus(); -} - -async function cancelReplyTarget() { - const draft = activeDraft(); - if (!draft || draft.editSourceMessageId) return; - draft.observedHeadId = displayedMessages().at(-1)?.id || null; - await persistWorkingItem(draft, true); - root.querySelector('textarea[name="message"]')?.focus(); -} - -function jumpToReplyTarget(messageId: string) { - const scrollToTarget = () => { - if (messageId === "__root__") { - root.querySelector("#thread-viewport")?.scrollTo({top: 0, behavior: "smooth"}); - return; - } - const target = root.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`); - target?.scrollIntoView({behavior: "smooth", block: "center"}); - target?.classList.add("reply-target-pulse"); - if (target) window.setTimeout(() => target.classList.remove("reply-target-pulse"), 900); - }; - if (messageId !== "__root__" && !root.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`) && knownMessageMap().has(messageId)) { - state.previewHeadId = newestBranchTip(messageId); - renderApp(); - window.requestAnimationFrame(scrollToTarget); - return; - } - scrollToTarget(); -} - -async function cancelEdit() { - const draft = activeDraft(); - if (!draft?.editSourceMessageId) return; - await discardWorkingItem(draft.id); - state.workingItems = state.workingItems.filter((item) => item.id !== draft.id); - state.activeDraftId = state.workingItems.find((item) => item.kind === "user-draft")?.id || ""; - state.composerFullscreen = false; - renderApp(); -} - -async function createDraft() { - if (!state.conversation) return; - const item = newDraftItem(state.conversation.id); - state.activeDraftId = item.id; - await persistWorkingItem(item, true); - root.querySelector('textarea[name="message"]')?.focus(); -} - -async function ensureActiveDraft() { - let draft = activeDraft(); - if (draft) return draft; - draft = newDraftItem(state.conversation!.id); - state.activeDraftId = draft.id; - state.workingItems.unshift(draft); - await persistWorkingItem(draft); - return draft; -} - -async function deleteWorking(id: string) { - await discardWorkingItem(id); - state.workingItems = state.workingItems.filter((item) => item.id !== id); - if (state.activeDraftId === id) { - state.activeDraftId = state.workingItems.find((item) => item.kind === "user-draft")?.id || ""; - if (!state.activeDraftId) state.composerFullscreen = false; - } - renderApp(); -} - -async function discardWorkingItem(id: string) { - window.clearTimeout(state.workingSaveTimers.get(id)); - state.workingSaveTimers.delete(id); - await removeWorkingItem(id); -} - -function checkpointWorkingItem(item: WorkingItem) { - window.clearTimeout(state.workingSaveTimers.get(item.id)); - state.workingSaveTimers.set(item.id, window.setTimeout(() => { - state.workingSaveTimers.delete(item.id); - void persistWorkingItem(item).catch((error) => console.error("Unable to checkpoint working item", error)); - }, 300)); -} - -async function generateAssistant(baseMessages: StoredChatMessage[]) { - if (!state.conversation) return; - const responseProvider = provider()!; - const responseModel = state.model; - const conversationId = state.conversation.id; - const baseHeadId = state.conversation.headMessageId; - const attemptId = uuid(); - const timestamp = messageNow(); - const working: WorkingItem = { - id: uuid(), - conversationId, - kind: "assistant-stream", - observedHeadId: baseHeadId, - parts: [], - status: "streaming", - attemptId, - providerId: state.providerId, - model: state.model, - createdAt: timestamp, - updatedAt: timestamp - }; - const assistant: StoredChatMessage = { - id: working.id, - parentMessageId: baseHeadId, - role: "assistant", - parts: [], - origin: {type: "model", providerId: state.providerId, model: state.model, attemptId}, - completion: {status: "partial"}, - createdAt: timestamp, - completedAt: timestamp - }; - state.conversation!.messages = [...baseMessages, assistant]; - state.streaming = true; - state.streamController = new AbortController(); - await persistWorkingItem(working); - renderMessages(true); - updateComposerControls(); - let text = ""; - let reasoning = ""; - let finished = false; - let cancelled = false; - let committedAssistantId = ""; - let titleConversation: Conversation | null = null; - const onEvent = (event: StreamEvent) => { - if (event.type === "text-delta" && event.text) text += event.text; - if (event.type === "reasoning-delta" && event.text) reasoning += event.text; - assistant.parts = [ - ...(reasoning ? [{type: "reasoning", text: reasoning}] : []), - ...(text ? [{type: "text", text}] : []) - ]; - working.parts = assistant.parts; - if (event.type === "finish" && event.metadata) { - assistant.metadata = {custom: {response: event.metadata}}; - working.metadata = assistant.metadata; - finished = true; - finishingMarkdownMessages.add(assistant.id); - } - if (event.type === "error") throw new Error(event.error || "生成失败"); - checkpointWorkingItem(working); - if (event.type === "finish") renderMessages(true); - else scheduleMessagesRender(); - }; - try { - if (provider()!.connection.type === "backend") await streamBackend(baseMessages, onEvent, state.streamController.signal); - else await streamFrontend(baseMessages, onEvent, state.streamController.signal); - if (!finished) throw new Error("Provider 未返回完成事件"); - const committedAssistant = await immutableMessage({ - parentMessageId: baseHeadId, - role: "assistant", - parts: assistant.parts, - origin: assistant.origin, - completion: {status: "complete"}, - metadata: assistant.metadata - }); - state.conversation = await commitConversationMessage(conversationId, { - id: committedAssistant.id, - expectedHeadId: baseHeadId, - parentMessageId: baseHeadId, - role: "assistant", - parts: committedAssistant.parts, - origin: committedAssistant.origin, - completion: committedAssistant.completion, - createdAt: committedAssistant.createdAt, - completedAt: committedAssistant.completedAt, - metadata: committedAssistant.metadata, - providerId: state.providerId, - model: state.model - }); - committedAssistantId = committedAssistant.id; - streamingMarkdownCaches.move(assistant.id, committedAssistantId); - finishingMarkdownMessages.delete(assistant.id); - finishingMarkdownMessages.add(committedAssistantId); - const streamedArticle = root.querySelector("#message-list > article.assistant-message:last-child"); - if (streamedArticle?.dataset.messageId === assistant.id) streamedArticle.dataset.messageId = committedAssistantId; - await removeWorkingItem(working.id); - state.workingItems = state.workingItems.filter((item) => item.id !== working.id); - await refreshConversations(); - if (!state.conversation.name) titleConversation = state.conversation; - scheduleRepositorySync(); - } catch (error) { - cancelled = state.streamController.signal.aborted; - working.status = cancelled ? "interrupted" : "failed"; - working.failureReason = cancelled ? "user-cancelled" : navigator.onLine ? "provider-error" : "connection-lost"; - working.parts = [ - ...(reasoning ? [{type: "reasoning", text: reasoning}] : []), - ...(text ? [{type: "text", text}] : []), - {type: "error", text: cancelled ? "已停止生成" : error instanceof Error ? error.message : "生成失败"} - ]; - await persistWorkingItem(working); - state.conversation!.messages = baseMessages; - } finally { - window.clearTimeout(state.workingSaveTimers.get(working.id)); - state.workingSaveTimers.delete(working.id); - state.streaming = false; - state.streamController = null; - if (committedAssistantId) { - renderMessages(true); - const historyList = root.querySelector(".history-list"); - if (historyList) historyList.innerHTML = renderHistoryItems(); - finishingMarkdownMessages.delete(committedAssistantId); - } else { - streamingMarkdownCaches.delete(assistant.id); - finishingMarkdownMessages.delete(assistant.id); - renderApp(); - } - const queuedDraft = state.workingItems.find((item) => item.id === state.queuedDraftId && item.kind === "user-draft"); - let submitQueuedDraft = false; - if (queuedDraft) { - if (queuedDraft.observedHeadId === assistant.id) { - queuedDraft.observedHeadId = committedAssistantId || baseHeadId; - await persistWorkingItem(queuedDraft); - } - submitQueuedDraft = Boolean(committedAssistantId) || cancelled && queuedDraft.incompleteTargetAction === "interrupt"; - } - state.queuedDraftId = ""; - updateComposerControls(); - updateStreamingControls(); - const composerNote = root.querySelector(".composer-note"); - if (composerNote) { - composerNote.classList.remove("queued"); - composerNote.textContent = state.offline ? "离线模式:提交保存在本地仓库,联网后自动 push。" : "草稿自动保存在此浏览器;模型可能会出错。"; - } - if (titleConversation) void generateConversationTitle(titleConversation, responseProvider, responseModel); - if (queuedDraft && submitQueuedDraft) void sendMessage(workingItemText(queuedDraft)).catch(showError); - } -} - -async function sendMessage(text: string) { - if (!state.conversation || !text.trim()) return; - const draft = activeDraft(); - if (state.streaming) { - if (!draft) return; - state.queuedDraftId = draft.id; - await persistWorkingItem(draft); - const activeAssistant = state.conversation.messages.at(-1); - if (activeAssistant?.role === "assistant" && activeAssistant.completion.status === "partial" && draft.observedHeadId === activeAssistant.id && draft.incompleteTargetAction === "interrupt") { - state.streamController?.abort(); - } - const note = root.querySelector(".composer-note"); - if (note) { - note.classList.add("queued"); - note.textContent = draft.incompleteTargetAction === "interrupt" ? "正在中断当前回答;随后会提交草稿。" : "已排队;会在当前回答完成后提交。"; - } - return; - } - const editing = Boolean(draft?.editSourceMessageId); - const messages = knownMessageMap(); - const selectedTargetId = draft ? draft.observedHeadId : displayedMessages().at(-1)?.id || null; - const selectedTarget = selectedTargetId ? messages.get(selectedTargetId) : null; - const parentMessageId = selectedTarget?.completion.status === "partial" && draft?.incompleteTargetAction === "interrupt" - ? selectedTarget.parentMessageId - : selectedTargetId; - if (editing && !messages.has(draft!.editSourceMessageId!)) throw new Error("要编辑的消息已不在本地消息图中"); - if (parentMessageId !== null && !messages.has(parentMessageId)) throw new Error("回复目标已不在本地消息图中"); - state.messageGraph = [...messages.values()]; - if (state.conversation.headMessageId !== parentMessageId) state.conversation = await moveConversationHead(state.conversation.id, parentMessageId); - state.previewHeadId = ""; - const role = draft?.messageRole || "user"; - const message = await immutableMessage({ - parentMessageId, - role, - parts: [{type: "text", text: text.trim()}], - origin: role === "assistant" - ? {type: "manual", clientId: window.localStorage.getItem("xiteng-chat-client-id") || "browser", ...(draft?.editSourceMessageId ? {sourceMessageId: draft.editSourceMessageId} : {})} - : {type: "user", clientId: window.localStorage.getItem("xiteng-chat-client-id") || "browser", ...(draft?.editSourceMessageId ? {sourceMessageId: draft.editSourceMessageId} : {})}, - completion: {status: "complete"} - }); - const input = root.querySelector('textarea[name="message"]'); - if (input) { - input.value = ""; - input.style.height = "auto"; - } - const draftId = draft?.id || ""; - state.conversation = await commitConversationMessage(state.conversation.id, { - id: message.id, - expectedHeadId: parentMessageId, - parentMessageId, - role, - parts: message.parts, - origin: message.origin, - completion: message.completion, - createdAt: message.createdAt, - completedAt: message.completedAt, - providerId: state.providerId, - model: state.model - }); - if (draftId) await discardWorkingItem(draftId); - state.workingItems = state.workingItems.filter((item) => item.id !== draftId); - state.activeDraftId = state.workingItems.find((item) => item.kind === "user-draft")?.id || ""; - state.composerFullscreen = false; - await refreshConversations(); - renderApp(); - scheduleRepositorySync(); - const requestAssistantReply = state.advancedActions - ? draft?.requestAssistantReply ?? true - : draft?.messageRole !== "assistant"; - if (requestAssistantReply) await generateAssistant(state.conversation.messages); -} - -async function regenerate(index: number) { - if (state.streaming || !state.conversation) return; - const visible = displayedMessages(); - const message = visible[index]; - if (!message || message.role !== "assistant") return; - const base = visible.slice(0, index); - if (!base.some((item) => item.role === "user")) return; - const baseHead = base.at(-1)?.id || null; - state.messageGraph = [...knownMessageMap().values()]; - state.conversation = await moveConversationHead(state.conversation.id, baseHead); - state.previewHeadId = ""; - await refreshConversations(); - scheduleRepositorySync(); - renderApp(); - await generateAssistant(state.conversation.messages); -} - -function nextAvailableConversationName(base: string) { - const conflicts = (candidate: string) => state.conversations.some((item) => item.name === candidate || item.name.startsWith(`${candidate}/`) || candidate.startsWith(`${item.name}/`)); - if (!conflicts(base)) return base; - const flattened = base.replaceAll("/", "-"); - let suffix = 2; - while (conflicts(`${flattened}-${suffix}`)) suffix += 1; - return `${flattened}-${suffix}`; -} - -function validatedConversationName(value: string, excludingId = "") { - const name = value.trim(); - if (!name || name.length > 300 || name.startsWith("/") || name.endsWith("/") || name.split("/").some((segment) => !segment || segment === "." || segment === ".." || /[\u0000-\u001f]/.test(segment))) { - window.alert("名称不能为空,不能以 / 开头或结尾,也不能包含空路径、.、.. 或控制字符。"); - return ""; - } - const conflict = state.conversations.find((item) => item.id !== excludingId && (item.name === name || item.name.startsWith(`${name}/`) || name.startsWith(`${item.name}/`))); - if (conflict) { - window.alert(`名称与现有会话“${conflict.name}”重名或存在路径前缀冲突。`); - return ""; - } - return name; -} - -async function renameConversation(id: string) { - const target = state.conversations.find((item) => item.id === id); - if (!target || !state.conversation) return; - const proposed = window.prompt("重命名会话(不会重命名同路径下的其他会话):", target.name); - if (proposed === null) return; - const name = validatedConversationName(proposed, id); - if (!name || name === target.name) return; - const updated = await updateConversationHistory(id, target.providerId, target.model, id === state.conversation.id ? state.generationSettings : (await getConversationHistory(id)).generationSettings, name); - if (state.conversation.id === id) state.conversation = updated; - await refreshConversations(); - renderApp(); - scheduleRepositorySync(); -} - -async function commitPartial(id: string) { - let item = state.workingItems.find((candidate) => candidate.id === id && candidate.kind === "assistant-stream"); - if (!item || !state.conversation) return; - let conversation = state.conversation; - if (conversation.headMessageId !== item.observedHeadId) { - const name = nextAvailableConversationName(`${conversation.name || untitledConversationLabel}-partial`); - const baseIndex = conversation.messages.findIndex((message) => message.id === item.observedHeadId); - const base = baseIndex >= 0 ? conversation.messages.slice(0, baseIndex + 1) : []; - conversation = await createConversationHistory(item.providerId || state.providerId, item.model || state.model, state.generationSettings, name, item.observedHeadId, base); - } - const completion: MessageCompletion = {status: "partial", reason: item.failureReason || "connection-lost"}; - const partial = await immutableMessage({ - parentMessageId: item.observedHeadId, - role: "assistant", - parts: item.parts, - origin: {type: "model", providerId: item.providerId || state.providerId, model: item.model || state.model, attemptId: item.attemptId || item.id}, - completion, - metadata: item.metadata - }); - state.conversation = await commitConversationMessage(conversation.id, { - id: partial.id, - expectedHeadId: item.observedHeadId, - parentMessageId: item.observedHeadId, - role: "assistant", - parts: partial.parts, - origin: partial.origin, - completion, - createdAt: partial.createdAt, - completedAt: partial.completedAt, - metadata: item.metadata, - providerId: item.providerId || state.providerId, - model: item.model || state.model - }); - await removeWorkingItem(item.id); - state.workingItems = state.workingItems.filter((candidate) => candidate.id !== item.id); - await refreshConversations(); - updateConversationHash(state.conversation.id, "push"); - renderApp(); - scheduleRepositorySync(); -} - -async function selectConversation(id: string, navigation: HashNavigationMode = "push") { - if (state.streaming || !state.config || state.conversation?.id === id) return; - const selected = await getConversationHistory(id); - const selectedProvider = state.config.providers.find((item) => item.id === selected.providerId) || state.config.providers[0]; - if (!selectedProvider) return; - state.conversation = selected; - state.previewHeadId = ""; - state.composerFullscreen = false; - state.providerId = selectedProvider.id; - state.model = selectedProvider.models.some((model) => model.id === selected.model) - ? selected.model - : settingsForProvider(selectedProvider).model; - state.generationSettings = selected.generationSettings; - await loadConversationWorkingItems(selected.id); - rememberModel(state.providerId, state.model); - closeHistoryOnMobile(); - if (navigation !== "none") updateConversationHash(selected.id, navigation); - renderApp(); -} - -function switchBranch(messageId: string) { - if (state.streaming || !knownMessageMap().has(messageId)) return; - const tip = newestBranchTip(messageId); - state.previewHeadId = tip === state.conversation?.headMessageId ? "" : tip; - renderApp(); -} - -async function confirmBranchPreview() { - if (!state.conversation || !state.previewHeadId) return; - state.messageGraph = [...knownMessageMap().values()]; - state.conversation = await moveConversationHead(state.conversation.id, state.previewHeadId); - state.previewHeadId = ""; - await refreshConversations(); - renderApp(); - scheduleRepositorySync(); -} - -async function newConversation() { - const item = provider(); - if (!item || !state.model) return; - const created = await createConversationHistory(item.id, state.model, state.generationSettings, ""); - state.conversation = created; - state.workingItems = []; - state.activeDraftId = ""; - state.composerFullscreen = false; - state.generationSettings = created.generationSettings; - await refreshConversations(); - closeHistoryOnMobile(); - updateConversationHash(created.id, "push"); - renderApp(); - scheduleRepositorySync(); -} - -async function removeConversation(id: string) { - const target = state.conversations.find((item) => item.id === id); - if (!target || !window.confirm(`删除会话“${target.name || untitledConversationLabel}”?消息节点仍会保留,直到以后手动清理。`)) return; - await deleteConversationHistory(id); - state.conversations = await listConversationHistory(); - if (state.conversation?.id === id) { - if (state.conversations[0]) await selectConversation(state.conversations[0].id, "replace"); - else await newConversation(); - } - renderApp(); - scheduleRepositorySync(); -} - -function chooseModel(providerId: string, model: string) { - const item = state.config?.providers.find((candidate) => candidate.id === providerId); - if (!item || !model || !state.conversation) return; - state.providerId = item.id; - state.model = model; - state.conversation = {...state.conversation, providerId: item.id, model}; - state.modelQuery = ""; - window.localStorage.setItem("xiteng-chat-provider", item.id); - window.localStorage.setItem(`xiteng-chat-model:${item.id}`, model); - rememberModel(item.id, model); - scheduleSettingsSave(); - renderApp(); -} - -function scheduleSettingsSave() { - window.clearTimeout(state.settingsTimer); - state.settingsTimer = window.setTimeout(() => { - if (!state.conversation) return; - void updateConversationHistory(state.conversation.id, state.providerId, state.model, state.generationSettings) - .then(() => scheduleRepositorySync()) - .catch((error) => console.error("Unable to save conversation settings", error)); - }, 400); -} - -async function configureLocal(providerId: string) { - const item = state.frontendProviders.find((candidate) => candidate.id === providerId); - if (!item) return; - const current = await getLocalCredential(item.id); - if (item.auth.type === "none") { - const baseUrl = window.prompt(`${item.name} Base URL:`, current?.secret.provider?.baseUrl || item.connection.baseUrl); - if (baseUrl === null) return; - try { - const parsed = new URL(baseUrl); - if (!["http:", "https:"].includes(parsed.protocol)) throw new Error(); - } catch { - window.alert("Base URL 必须是有效的 http 或 https URL"); - return; - } - await saveLocalCredential(item.id, "default", {...current?.secret, provider: {...current?.secret.provider, baseUrl: baseUrl.replace(/\/+$/, "")}}); - } else { - const apiKey = window.prompt(`${item.name} API Key:`, current?.secret.provider?.apiKey || ""); - if (apiKey === null) return; - const proxyToken = item.connection.proxy?.type === "relay" ? window.prompt("Relay Token(没有则留空):", current?.secret.proxy?.token || "") || "" : ""; - await saveLocalCredential(item.id, "default", {provider: {apiKey}, ...(proxyToken ? {proxy: {token: proxyToken}} : {})}); - } - window.location.reload(); -} - -async function probeLocal(providerId: string) { - const item = state.frontendProviders.find((candidate) => candidate.id === providerId); - if (!item) return; - try { - const detected = await discoverFrontendProvider(item, (await getLocalCredential(item.id))?.secret || {}); - state.frontendProviders = state.frontendProviders.map((candidate) => candidate.id === detected.id ? detected : candidate); - if (state.config) state.config.providers = [...state.config.providers.filter((candidate) => candidate.id !== detected.id), detected]; - window.alert(`探测成功:发现 ${detected.models.length} 个模型`); - renderApp(); - } catch (error) { - window.alert(`探测失败:${error instanceof Error ? error.message : "未知错误"}\n\n请确认浏览器已允许 xiteng.site 的“本地网络访问”权限。`); - } -} - -async function initializeLocalMode(clientId: string) { - const response = await fetch(appUrl("/api/public-config"), {cache: "no-store"}); - const payload = await response.json() as PublicChatConfig & {error?: string}; - if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); - const profileId = `local:${clientId}`; - state.authenticated = false; - state.identityKey = profileId; - state.syncing = false; - state.syncRequested = false; - state.initialFetchComplete = true; - state.lastFetchAt = ""; - state.syncError = ""; - state.error = ""; - activateOfflineProfile(profileId); - - const fallbackProviders: ChatProvider[] = payload.providers.map((item) => ({ - ...item, - models: [{id: item.defaultModel || "local-model", name: item.defaultModel || "local-model"}] - })); - state.frontendProviders = fallbackProviders; - state.config = {profile: {username: "local", name: "本地用户", email: ""}, providers: fallbackProviders}; - await cacheChatConfig(profileId, {config: state.config, frontendProviders: fallbackProviders}); - state.conversations = await listConversationHistory(); - const hashId = conversationIdFromHash(window.location.hash); - const summary = state.conversations.find((item) => item.id === hashId) || state.conversations[0]; - const savedProviderId = window.localStorage.getItem("xiteng-chat-provider") || ""; - let selectedProvider = fallbackProviders.find((item) => item.id === summary?.providerId) - || fallbackProviders.find((item) => item.id === savedProviderId) - || fallbackProviders[0]; - if (!selectedProvider) throw new Error("没有可用的浏览器本地 Provider"); - if (summary) { - state.conversation = await getConversationHistory(summary.id); - selectedProvider = fallbackProviders.find((item) => item.id === state.conversation!.providerId) || selectedProvider; - } else { - const selection = settingsForProvider(selectedProvider); - state.conversation = await createConversationHistory(selectedProvider.id, selection.model, defaultGenerationSettings, ""); - state.conversations = [state.conversation]; - } - const conversation = state.conversation!; - state.providerId = selectedProvider.id; - state.model = selectedProvider.models.some((model) => model.id === conversation.model) - ? conversation.model - : settingsForProvider(selectedProvider).model; - state.generationSettings = conversation.generationSettings; - await loadConversationWorkingItems(conversation.id); - rememberModel(state.providerId, state.model); - updateConversationHash(conversation.id, "replace"); - state.loading = false; - state.offline = !navigator.onLine; - renderApp(); - - void Promise.all(fallbackProviders.map(async (item) => { - const credential = state.localCredentials.find((value) => value.providerId === item.id && value.name === "default") - || state.localCredentials.find((value) => value.providerId === item.id); - if (!credential) return item; - try { - return await discoverFrontendProvider(item, credential?.secret || {}); - } catch (error) { - return {...item, modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"}; - } - })).then(async (providers) => { - if (state.authenticated || state.identityKey !== profileId || !state.config) return; - state.frontendProviders = providers; - state.config.providers = providers; - const active = providers.find((item) => item.id === state.providerId); - if (active?.models.length && !active.models.some((model) => model.id === state.model)) state.model = settingsForProvider(active).model; - await cacheChatConfig(profileId, {config: state.config, frontendProviders: providers}); - renderApp(); - }); -} - -async function initialize() { - if (!window.localStorage.getItem("xiteng-chat-client-id")) window.localStorage.setItem("xiteng-chat-client-id", uuid()); - const clientId = window.localStorage.getItem("xiteng-chat-client-id")!; - const repositoryId = `local:${clientId}`; - state.localCredentials = await listLocalCredentials(); - try { - const recent = JSON.parse(window.localStorage.getItem("xiteng-chat-recent-models") || "[]"); - if (Array.isArray(recent)) state.recentModelKeys = recent.filter((item) => typeof item === "string").slice(0, 20); - } catch { - window.localStorage.removeItem("xiteng-chat-recent-models"); - } - const previouslyActive = await loadCachedChatConfig(); - if (previouslyActive && previouslyActive.profileId !== repositoryId) await mergeOfflineProfiles(previouslyActive.profileId, repositoryId); - activateOfflineProfile(repositoryId); - const stored = await loadCachedChatConfig(repositoryId); - let renderedLocalRepository = false; - if (stored) { - state.identityKey = stored.profileId; - activateOfflineProfile(stored.profileId); - state.config = stored.config.config; - state.frontendProviders = stored.config.frontendProviders; - state.lastFetchAt = stored.lastFetchAt || await cachedLastFetchAt(); - state.conversations = await listConversationHistory(); - const hashId = conversationIdFromHash(window.location.hash); - const summary = state.conversations.find((item) => item.id === hashId) || state.conversations[0]; - if (summary) { - const selected = await getConversationHistory(summary.id); - const selectedProvider = state.config.providers.find((item) => item.id === selected.providerId) || state.config.providers[0]; - if (selectedProvider) { - state.conversation = selected; - state.providerId = selectedProvider.id; - state.model = selectedProvider.models.some((model) => model.id === selected.model) ? selected.model : settingsForProvider(selectedProvider).model; - state.generationSettings = selected.generationSettings; - await loadConversationWorkingItems(selected.id); - rememberModel(state.providerId, state.model); - updateConversationHash(selected.id, "replace"); - state.loading = false; - state.syncing = navigator.onLine; - state.offline = !navigator.onLine; - renderApp(); - renderedLocalRepository = true; - } - } else { - const savedProviderId = window.localStorage.getItem("xiteng-chat-provider") || ""; - const selectedProvider = state.config.providers.find((item) => item.id === savedProviderId) || state.config.providers[0]; - if (selectedProvider) { - const selection = settingsForProvider(selectedProvider); - state.conversation = await createConversationHistory(selectedProvider.id, selection.model, defaultGenerationSettings, ""); - state.conversations = [state.conversation]; - state.providerId = selectedProvider.id; - state.model = selection.model; - state.generationSettings = state.conversation.generationSettings; - state.loading = false; - state.syncing = navigator.onLine; - state.offline = !navigator.onLine; - updateConversationHash(state.conversation.id, "replace"); - renderApp(); - renderedLocalRepository = true; - } - } - } - - try { - const response = await fetch(appUrl("/api/config"), {cache: "no-store", redirect: "manual"}); - if (response.type === "opaqueredirect" || response.status === 0 || response.status === 401 || response.status >= 300 && response.status < 400) { - await initializeLocalMode(clientId); - return; - } - const payload = await response.json() as ServerChatConfig & {error?: string}; - if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); - state.authenticated = true; - await mergeOfflineProfiles(payload.identityKey, repositoryId); - state.identityKey = repositoryId; - activateOfflineProfile(repositoryId); - const providers = await Promise.all(payload.providers.map(async (item) => { - if (item.connection.type === "backend") return item; - const credential = state.localCredentials.find((value) => value.providerId === item.id && value.name === "default") || state.localCredentials.find((value) => value.providerId === item.id); - if (!credential) return item.auth.type === "none" ? item : {...item, models: []}; - try { - return await discoverFrontendProvider(item, credential?.secret || {}); - } catch (error) { - return {...item, models: item.models.length ? item.models : [{id: item.defaultModel || "local-model", name: item.defaultModel || "local-model"}], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"}; - } - })); - state.frontendProviders = providers.filter((item) => item.connection.type === "frontend"); - const configured: ChatConfig = { - profile: payload.profile, - providers: providers.filter((item) => item.models.length > 0 && (item.connection.type === "backend" ? item.credentials.length > 0 : item.auth.type === "none" || state.localCredentials.some((credential) => credential.providerId === item.id))) - }; - state.config = configured; - await cacheChatConfig(repositoryId, {config: configured, frontendProviders: state.frontendProviders}); - state.syncing = true; - updateSyncIndicator(); - const synchronized = await synchronizeConversationRepository(); - state.lastFetchAt = synchronized.fetchedAt; - state.initialFetchComplete = true; - state.syncError = synchronized.conflicts ? `${synchronized.conflicts} 个会话发生分叉,本地 head 已保留` : ""; - state.offline = false; - state.conversations = synchronized.summaries; - const hashId = conversationIdFromHash(window.location.hash); - const selectedId = state.conversation && state.conversations.some((item) => item.id === state.conversation!.id) - ? state.conversation.id - : state.conversations.find((item) => item.id === hashId)?.id || state.conversations[0]?.id; - if (selectedId) { - const selected = await getConversationHistory(selectedId); - const selectedProvider = configured.providers.find((item) => item.id === selected.providerId) || configured.providers[0]; - if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据"); - state.conversation = selected; - state.providerId = selectedProvider.id; - state.model = selectedProvider.models.some((model) => model.id === selected.model) ? selected.model : settingsForProvider(selectedProvider).model; - state.generationSettings = selected.generationSettings; - await loadConversationWorkingItems(selected.id); - rememberModel(state.providerId, state.model); - updateConversationHash(selected.id, "replace"); - } else { - const savedProviderId = window.localStorage.getItem("xiteng-chat-provider") || ""; - const selectedProvider = configured.providers.find((item) => item.id === savedProviderId) || configured.providers[0]; - if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据"); - const selection = settingsForProvider(selectedProvider); - state.conversation = await createConversationHistory(selectedProvider.id, selection.model, defaultGenerationSettings, ""); - state.conversations = [state.conversation]; - state.workingItems = []; - state.activeDraftId = ""; - state.providerId = selectedProvider.id; - state.model = selection.model; - state.generationSettings = state.conversation.generationSettings; - rememberModel(state.providerId, state.model); - updateConversationHash(state.conversation.id, "replace"); - scheduleRepositorySync(); - } - state.loading = false; - state.syncing = false; - renderApp(); - if (state.syncRequested) scheduleRepositorySync(); - } catch (error) { - if (!renderedLocalRepository && navigator.onLine) { - try { - await initializeLocalMode(clientId); - return; - } catch {} - } - state.syncing = false; - state.offline = !navigator.onLine; - state.syncError = error instanceof Error ? error.message : "Fetch failed"; - if (!renderedLocalRepository) throw error; - updateSyncIndicator(); - if (state.syncRequested && navigator.onLine) scheduleRepositorySync(1000); - } -} - -root.addEventListener("submit", (event) => { - if (!(event.target instanceof HTMLFormElement) || event.target.id !== "composer") return; - event.preventDefault(); - const input = event.target.elements.namedItem("message"); - if (input instanceof HTMLTextAreaElement) void sendMessage(input.value).catch(showError); -}); - -root.addEventListener("keydown", (event) => { - if (event.key === "Escape" && state.settingsOpen) { - state.settingsOpen = false; - state.modelQuery = ""; - renderApp(); - return; - } - if (event.key === "Escape" && state.importPanelOpen && !state.importing) { - state.importPanelOpen = false; - renderApp(); - return; - } - if (event.key === "Escape" && state.composerFullscreen) { - event.preventDefault(); - setComposerFullscreen(false); - return; - } - if (!(event.target instanceof HTMLTextAreaElement) || event.target.name !== "message") return; - const fullscreenSubmit = state.composerFullscreen && event.key === "Enter" && (event.ctrlKey || event.metaKey); - const compactSubmit = !state.composerFullscreen && event.key === "Enter" && !event.shiftKey; - if (fullscreenSubmit || compactSubmit) { - event.preventDefault(); - void sendMessage(event.target.value).catch(showError); - } -}); - -root.addEventListener("input", (event) => { - const target = event.target; - if (target instanceof HTMLTextAreaElement && target.name === "message") { - target.style.height = "auto"; - target.style.height = `${Math.min(target.scrollHeight, 180)}px`; - if (state.conversation) { - let draft = activeDraft(); - if (!draft) { - draft = newDraftItem(state.conversation.id); - state.activeDraftId = draft.id; - state.workingItems.unshift(draft); - } - draft.parts = target.value ? [{type: "text", text: target.value}] : []; - draft.updatedAt = messageNow(); - checkpointWorkingItem(draft); - } - } - if (target instanceof HTMLInputElement && target.dataset.action === "model-search") { - state.modelQuery = target.value; - const details = target.closest("details"); - renderApp(); - const next = root.querySelector('[data-action="model-search"]'); - const nextDetails = next?.closest("details"); - if (nextDetails) nextDetails.open = true; - next?.focus(); - next?.setSelectionRange(next.value.length, next.value.length); - if (details?.open && nextDetails) nextDetails.open = true; - } - if (target instanceof HTMLInputElement && target.dataset.action === "import-title-template") { - state.importTitleTemplate = target.value; - window.localStorage.setItem("xiteng-chat-import-title-template", target.value); - const preview = importTitleTemplatePreview(); - target.setAttribute("aria-invalid", String(preview.error)); - const output = root.querySelector(".session-title-template-preview"); - if (output) { - output.textContent = preview.text; - output.classList.toggle("error", preview.error); - } - } -}); - -root.addEventListener("change", (event) => { - const target = event.target; - if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) return; - if (target.dataset.action === "advanced-actions" && target instanceof HTMLInputElement) { - state.advancedActions = target.checked; - window.localStorage.setItem("xiteng-chat-advanced-actions", target.checked ? "1" : "0"); - renderApp(); - return; - } - if (target.dataset.action === "history-tree-setting" && target instanceof HTMLInputElement) { - state.historyTree = target.checked; - window.localStorage.setItem("xiteng-chat-history-tree", target.checked ? "1" : "0"); - renderApp(); - return; - } - if (target.dataset.action === "session-file" && target instanceof HTMLInputElement) { - const files = [...(target.files || [])]; - if (files.length) void expandImportFiles(files).then((items) => importSessionFiles(items, `${files.length} 个所选文件`)).catch(showError).finally(() => { target.value = ""; }); - else target.value = ""; - return; - } - if (target.dataset.action === "session-archive" && target instanceof HTMLInputElement) { - const files = [...(target.files || [])]; - if (files.length) void expandImportFiles(files).then((items) => importSessionFiles(items, `${files.length} 个 ZIP 压缩包`)).catch(showError).finally(() => { target.value = ""; }); - else target.value = ""; - return; - } - if (target.dataset.action === "session-directory" && target instanceof HTMLInputElement) { - const files = [...(target.files || [])]; - if (files.length) void expandImportFiles(files).then((items) => importSessionFiles(items, "所选文件夹")).catch(showError).finally(() => { target.value = ""; }); - else target.value = ""; - return; - } - if (target.dataset.action === "request-assistant-reply" && target instanceof HTMLInputElement && state.conversation) { - void ensureActiveDraft().then(async (draft) => { - draft.requestAssistantReply = target.checked; - await persistWorkingItem(draft); - }).catch(showError); - return; - } - if (target.dataset.action === "incomplete-target-action" && target instanceof HTMLSelectElement && state.conversation) { - void ensureActiveDraft().then(async (draft) => { - draft.incompleteTargetAction = target.value === "interrupt" ? "interrupt" : "append"; - await persistWorkingItem(draft); - }).catch(showError); - return; - } - if (!target.dataset.setting) return; - const key = target.dataset.setting as keyof GenerationSettings; - if (key === "showReasoningSummary" && target instanceof HTMLInputElement) state.generationSettings.showReasoningSummary = target.checked; - if (key === "reasoning") state.generationSettings.reasoning = target.value as GenerationSettings["reasoning"]; - if (key === "temperature") state.generationSettings.temperature = target.value === "" ? null : Math.min(2, Math.max(0, Number(target.value))); - if (key === "maxOutputTokens") state.generationSettings.maxOutputTokens = target.value === "" ? null : Math.min(1_000_000, Math.max(1, Math.floor(Number(target.value)))); - if (state.conversation) state.conversation.generationSettings = {...state.generationSettings}; - scheduleSettingsSave(); -}); - -root.addEventListener("click", (event) => { - const button = (event.target as Element).closest("[data-action]"); - if (!button) return; - const action = button.dataset.action; - if (action === "open-settings") { state.settingsOpen = true; state.modelQuery = ""; renderApp(); } - if (action === "close-settings") { state.settingsOpen = false; state.modelQuery = ""; renderApp(); } - if (action === "scroll-settings-section" && button.dataset.id) scrollSettingsSection(button.dataset.id); - if (action === "toggle-history") { state.historyOpen = !state.historyOpen; renderApp(); } - if (action === "close-history") { state.historyOpen = false; renderApp(); } - if (action === "toggle-history-tree") { - state.historyTree = !state.historyTree; - window.localStorage.setItem("xiteng-chat-history-tree", state.historyTree ? "1" : "0"); - renderApp(); - } - if (action === "import-session") { state.importPanelOpen = true; state.importStatus = ""; renderApp(); } - if (action === "close-import-panel" && !state.importing && (!(event.target instanceof Element) || !event.target.closest("[data-import-panel]") || button.matches("button"))) { state.importPanelOpen = false; renderApp(); } - if (action === "choose-session-files") root.querySelector('[data-action="session-file"]')?.click(); - if (action === "choose-session-archive") root.querySelector('[data-action="session-archive"]')?.click(); - if (action === "choose-session-directory") void chooseImportDirectory().catch(showError); - if (action === "export-session" && button.dataset.format) void exportSessions(button.dataset.format as SessionTransferFormat).catch(showError); - if (action === "new-conversation") void newConversation().catch(showError); - if (action === "switch-branch" && button.dataset.id) switchBranch(button.dataset.id); - if (action === "leave-branch-preview") { state.previewHeadId = ""; renderApp(); } - if (action === "confirm-branch-preview") void confirmBranchPreview().catch(showError); - if (action === "select-conversation" && button.dataset.id) void selectConversation(button.dataset.id).catch(showError); - if (action === "rename-conversation" && button.dataset.id) void renameConversation(button.dataset.id).catch(showError); - if (action === "delete-conversation" && button.dataset.id) void removeConversation(button.dataset.id).catch(showError); - if (action === "new-draft") void createDraft().catch(showError); - if (action === "toggle-composer-fullscreen") setComposerFullscreen(!state.composerFullscreen); - if (action === "cancel-edit") void cancelEdit().catch(showError); - if (action === "reply-message") void replyToMessage(Number(button.dataset.index)).catch(showError); - if (action === "cancel-reply-target") void cancelReplyTarget().catch(showError); - if (action === "jump-reply-target" && button.dataset.id) jumpToReplyTarget(button.dataset.id); - if (action === "select-draft" && button.dataset.id) { - state.activeDraftId = button.dataset.id; - renderApp(); - root.querySelector('textarea[name="message"]')?.focus(); - } - if (action === "delete-working" && button.dataset.id) void deleteWorking(button.dataset.id).catch(showError); - if (action === "commit-partial" && button.dataset.id) void commitPartial(button.dataset.id).catch(showError); - if (action === "choose-model" && button.dataset.provider && button.dataset.model) chooseModel(button.dataset.provider, button.dataset.model); - if (action === "configure-local" && button.dataset.provider) void configureLocal(button.dataset.provider).catch(showError); - if (action === "probe-local" && button.dataset.provider) void probeLocal(button.dataset.provider).catch(showError); - if (action === "delete-local" && button.dataset.provider) { - const item = state.frontendProviders.find((candidate) => candidate.id === button.dataset.provider); - if (item && window.confirm(`删除此浏览器中的 ${item.name} Credential?`)) void deleteLocalCredential(item.id).then(() => window.location.reload()); - } - if (action === "reset-settings") { state.generationSettings = {...defaultGenerationSettings}; scheduleSettingsSave(); renderApp(); } - if (action === "stop") state.streamController?.abort(); - if (action === "scroll-bottom") scrollBottom("smooth"); - if (action === "copy-message") { - const index = Number(button.dataset.index); - const message = displayedMessages()[index]; - if (message) void navigator.clipboard.writeText(messagePartText(message, "text")).then(() => { - button.classList.add("copied"); - window.setTimeout(() => button.classList.remove("copied"), 1200); - }); - } - if (action === "edit-message") void editMessage(Number(button.dataset.index)).catch(showError); - if (action === "regenerate-message") void regenerate(Number(button.dataset.index)).catch(showError); -}); - -function showError(error: unknown) { - window.alert(error instanceof Error ? error.message : "操作失败"); -} - -window.addEventListener("hashchange", () => { - const id = conversationIdFromHash(window.location.hash); - if (id && id !== state.conversation?.id && state.conversations.some((item) => item.id === id)) void selectConversation(id, "none").catch(showError); -}); -window.addEventListener("offline", () => { - state.offline = true; - state.syncing = false; - state.syncError = "当前离线,提交保存在本地仓库"; - renderApp(); -}); -window.addEventListener("online", () => { - state.offline = false; - scheduleRepositorySync(0); - renderApp(); -}); -window.matchMedia("(min-width: 681px)").addEventListener("change", (event) => { state.historyOpen = event.matches; renderApp(); }); -window.addEventListener("pagehide", () => { - const draft = activeDraft(); - if (draft) void persistWorkingItem(draft); -}); -document.addEventListener("visibilitychange", () => { - if (document.visibilityState !== "hidden") return; - const draft = activeDraft(); - if (draft) void persistWorkingItem(draft); -}); - -if ("serviceWorker" in navigator) navigator.serviceWorker.register(appUrl("/sw.js?v=7"), {scope: `${basePath}/`}).catch((error) => console.error("Unable to register service worker", error)); - -renderApp(); -initialize().catch((error) => { - state.loading = false; - state.error = error instanceof Error ? error.message : "配置加载失败"; - renderApp(); -}); diff --git a/chat/src/index.html b/chat/src/index.html deleted file mode 100644 index 6ac4f5e..0000000 --- a/chat/src/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - liooil / xiteng.site - - - - - - - - -
- - diff --git a/chat/src/server.ts b/chat/src/server.ts deleted file mode 100644 index 8a03e38..0000000 --- a/chat/src/server.ts +++ /dev/null @@ -1,576 +0,0 @@ -import {createHash, randomUUID} from "node:crypto"; -import {readFileSync} from "node:fs"; -import path from "node:path"; -import {convertToModelMessages, streamText, type UIMessage} from "ai"; -import { - appendConversationMessage, - createConversation, - deleteConversation, - fetchRepository, - getConversation, - listConversations, - pushRepositoryRef, - putRepositoryObjects, - saveConversationMessages, - updateConversation -} from "../lib/conversations"; -import {generationCallOptions, normalizeGenerationSettings} from "../lib/generation-settings"; -import {identityFromHeaders, keyVaultFetch, type ChatIdentity} from "../lib/key-vault"; -import {discoverProviderModels, testProviderConnectivity} from "../lib/provider-connectivity"; -import {createProviderModel} from "../lib/provider-model"; -import {createServerProviderFetch} from "../lib/server-provider-fetch"; -import type {ProviderDefinition, ProviderSecret, ResolvedBackendProvider} from "../lib/provider-types"; -import {responseMetadata} from "../lib/response-metadata"; -import type {RepositoryRefUpdate, StoredChatMessage} from "../lib/conversation-types"; -import {validMessageObjectId} from "../lib/message-object"; - -const port = Number.parseInt(process.env.PORT || "3000", 10); -const portalUrl = process.env.PORTAL_URL || "http://xiteng-site:8080"; -const staticRoot = path.resolve(process.env.STATIC_ROOT || "dist"); -const publicProviderCatalogFile = path.resolve(process.env.PUBLIC_PROVIDER_CATALOG_FILE || "../ai-gateway/providers.json"); -const encoder = new TextEncoder(); - -const basePath = "/chat"; -const securityHeaders = { - "Content-Security-Policy": [ - "default-src 'self'", - "base-uri 'self'", - "object-src 'none'", - "frame-src 'none'", - "frame-ancestors 'none'", - "form-action 'self'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https:", - "font-src 'self' data:", - "manifest-src 'self'", - "worker-src 'self' blob:", - "connect-src 'self' http: https: ws: wss:" - ].join("; "), - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY" -}; - -function json(payload: unknown, status = 200, headers: HeadersInit = {}) { - return Response.json(payload, {status, headers: {...securityHeaders, "Cache-Control": "no-store", ...headers}}); -} - -function identityKey(identity: ChatIdentity) { - return createHash("sha256").update(`${identity.issuer}\0${identity.sub}`).digest("hex").slice(0, 32); -} - -function errorStatus(error: unknown, fallback: number) { - return typeof error === "object" && error && "statusCode" in error ? Number(error.statusCode) : fallback; -} - -async function accountProfile(identity: ChatIdentity) { - const fallback = {username: identity.username, name: identity.name || identity.username, email: identity.email}; - try { - const response = await fetch(new URL("/api/account/identity", portalUrl), { - headers: { - "Accept": "application/json", - "X-Portal-Authenticated": "1", - "X-Authentik-Username": identity.username, - "X-Authentik-Uid": identity.sub, - "X-Authentik-Email": identity.email - }, - signal: AbortSignal.timeout(10000) - }); - if (!response.ok) return fallback; - const payload = await response.json() as {profile?: {username?: string; name?: string; email?: string}}; - return { - username: payload.profile?.username?.trim() || fallback.username, - name: payload.profile?.name?.trim() || fallback.name, - email: payload.profile?.email?.trim() || fallback.email - }; - } catch { - return fallback; - } -} - -async function discoverBackendProvider(provider: ProviderDefinition, identity: ChatIdentity) { - if (provider.connection.type !== "backend" || !provider.credentials.length) return {...provider, models: []}; - const credential = provider.credentials.find((item) => item.name === "default") || provider.credentials[0]; - const response = await keyVaultFetch("/v1/resolve", identity, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({providerId: provider.id, credentialName: credential.name}) - }); - const resolved = await response.json() as ResolvedBackendProvider & {error?: string}; - if (!response.ok) return {...provider, models: [], modelDiscoveryError: resolved.error || `Key Vault HTTP ${response.status}`}; - try { - const discovery = await discoverProviderModels( - resolved.provider, - resolved.credential.secret, - createServerProviderFetch(resolved.provider, resolved.credential.secret) - ); - return {...provider, models: discovery.models}; - } catch (error) { - return {...provider, models: [], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"}; - } -} - -async function config(request: Request) { - try { - const identity = identityFromHeaders(request.headers); - const response = await keyVaultFetch("/v1/providers", identity); - const payload = await response.json() as {providers?: ProviderDefinition[]; error?: string}; - if (!response.ok) return json({error: payload.error || `Key Vault HTTP ${response.status}`}, response.status); - const [providers, profile] = await Promise.all([ - Promise.all((payload.providers || []).map((provider) => discoverBackendProvider(provider, identity))), - accountProfile(identity) - ]); - return json({providers, identityKey: identityKey(identity), profile}); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Provider configuration unavailable"}, errorStatus(error, 503)); - } -} - -function publicFrontendProviders(): ProviderDefinition[] { - const definitions = JSON.parse(readFileSync(publicProviderCatalogFile, "utf8")) as Array>; - return definitions.flatMap((definition) => { - const connection = definition.connection as {type?: string; baseUrl?: string; proxy?: ProviderDefinition["connection"]["proxy"]} | undefined; - if (connection?.type !== "frontend" || typeof connection.baseUrl !== "string") return []; - const api = definition.api as ProviderDefinition["api"]; - const baseUrl = connection.baseUrl.replace(/\/+$/, ""); - const discoveryType = api === "anthropic-messages" - ? "anthropic-models-list" - : api === "google-generative-ai" ? "google-models-list" : "openai-models-list"; - const discoveryUrl = discoveryType === "anthropic-models-list" - ? `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200` - : discoveryType === "google-models-list" ? `${baseUrl}/models?pageSize=200` : `${baseUrl}/models`; - return [{ - id: String(definition.id || ""), - name: String(definition.name || definition.id || ""), - api, - connection: {type: "frontend" as const, baseUrl, proxy: connection.proxy || null}, - auth: (definition.auth || {type: "none"}) as ProviderDefinition["auth"], - headers: definition.headers && typeof definition.headers === "object" ? definition.headers as Record : {}, - defaultModel: String(definition.defaultModel || "local-model"), - discovery: {type: discoveryType, url: discoveryUrl}, - builtin: true, - credentialState: "local" as const, - credentials: [] - }]; - }); -} - -function publicConfig() { - try { - return json({providers: publicFrontendProviders()}); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Public Provider configuration unavailable"}, 503); - } -} - -async function conversations(request: Request) { - try { - const identity = identityFromHeaders(request.headers); - if (request.method === "GET") return json({conversations: listConversations(identity)}); - if (request.method === "POST") return json({conversation: createConversation(identity, await request.json())}, 201); - return json({error: "Method not allowed"}, 405, {Allow: "GET, POST"}); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Conversation request failed"}, request.method === "POST" ? 400 : 500); - } -} - -async function conversation(request: Request, id: string) { - try { - const identity = identityFromHeaders(request.headers); - if (request.method === "GET") { - const value = getConversation(identity, id); - return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404); - } - if (request.method === "PUT") { - const value = saveConversationMessages(identity, id, await request.json()); - return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404); - } - if (request.method === "PATCH") { - const value = updateConversation(identity, id, await request.json()); - return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404); - } - if (request.method === "DELETE") { - return deleteConversation(identity, id) - ? new Response(null, {status: 204, headers: securityHeaders}) - : json({error: "Conversation not found"}, 404); - } - return json({error: "Method not allowed"}, 405, {Allow: "GET, PUT, PATCH, DELETE"}); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Conversation request failed"}, 400); - } -} - -async function conversationMessage(request: Request, id: string) { - try { - if (request.method !== "POST") return json({error: "Method not allowed"}, 405, {Allow: "POST"}); - const identity = identityFromHeaders(request.headers); - const result = appendConversationMessage(identity, id, await request.json()); - if (result.status === "missing") return json({error: "Conversation not found"}, 404); - if (result.status === "conflict") return json({error: "Conversation head changed", conversation: result.conversation}, 409); - return json({conversation: result.conversation}, 201); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Message commit failed"}, 400); - } -} - -async function repositoryFetch(request: Request) { - try { - if (request.method !== "POST") return json({error: "Method not allowed"}, 405, {Allow: "POST"}); - const identity = identityFromHeaders(request.headers); - const input = await request.json() as {haveObjectIds?: unknown}; - return json(fetchRepository(identity, input.haveObjectIds)); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Repository fetch failed"}, 400); - } -} - -async function repositoryPush(request: Request) { - try { - if (request.method !== "POST") return json({error: "Method not allowed"}, 405, {Allow: "POST"}); - const identity = identityFromHeaders(request.headers); - const input = await request.json() as {repositoryId?: unknown; objects?: unknown; refs?: unknown}; - const repositoryId = typeof input.repositoryId === "string" && /^local:[a-zA-Z0-9-]{8,160}$/.test(input.repositoryId) - ? input.repositoryId - : ""; - const objects = Array.isArray(input.objects) ? input.objects as StoredChatMessage[] : []; - for (const object of objects) { - const validForRepository = repositoryId ? await validMessageObjectId(object, repositoryId) : false; - const validLegacyObject = validForRepository ? false : await validMessageObjectId(object, identityKey(identity)); - if (!validForRepository && !validLegacyObject) return json({error: `Object ${object?.id || "unknown"} failed content verification`}, 400); - } - const insertedObjects = putRepositoryObjects(identity, objects); - const refs = Array.isArray(input.refs) ? input.refs as RepositoryRefUpdate[] : []; - if (refs.length > 100) return json({error: "refs must contain at most 100 entries"}, 400); - const results = refs.map((update) => ({conversationId: update.conversationId, ...pushRepositoryRef(identity, update)})); - return json({insertedObjects, refs: results, pushedAt: new Date().toISOString()}); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Repository push failed"}, 400); - } -} - -function temporaryProvider(value: unknown): ProviderDefinition { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("provider is required"); - const input = value as Record; - const connectionInput = input.connection as Record | undefined; - const authInput = input.auth as Record | undefined; - const discoveryInput = input.discovery as Record | undefined; - const id = String(input.id || "").trim().toLowerCase(); - const name = String(input.name || "").trim(); - const api = String(input.api || ""); - if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id is invalid"); - if (!name) throw new Error("provider.name is required"); - if (!["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"].includes(api)) throw new Error("provider.api is invalid"); - if (connectionInput?.type !== "backend") throw new Error("Only Backend Provider drafts can be tested by the Chat server"); - const baseUrl = new URL(String(connectionInput.baseUrl || "")); - if (!["http:", "https:"].includes(baseUrl.protocol)) throw new Error("provider baseUrl is invalid"); - const proxyInput = connectionInput.proxy as Record | null | undefined; - let proxy: ProviderDefinition["connection"]["proxy"] = null; - if (proxyInput) { - const type = String(proxyInput.type || "") as "http" | "https" | "socks5"; - if (!["http", "https", "socks5"].includes(type)) throw new Error("provider proxy type is invalid"); - const url = new URL(String(proxyInput.url || "")); - if (type === "socks5" ? url.protocol !== "socks5:" : !["http:", "https:"].includes(url.protocol)) throw new Error("provider proxy URL is invalid"); - proxy = {type, url: url.toString().replace(/\/$/, "")}; - } - const defaultModel = String(input.defaultModel || "").trim().slice(0, 300); - if (!defaultModel) throw new Error("provider.defaultModel is required"); - const authType = ["bearer", "header", "none"].includes(String(authInput?.type)) ? String(authInput?.type) as "bearer" | "header" | "none" : "bearer"; - const header = authType === "header" ? String(authInput?.header || "").trim() : ""; - if (authType === "header" && !header) throw new Error("provider auth header is required"); - const discoveryType = String(discoveryInput?.type || ""); - if (!["openai-models-list", "anthropic-models-list", "google-models-list"].includes(discoveryType)) throw new Error("provider.discovery.type is invalid"); - const discoveryUrl = new URL(String(discoveryInput?.url || "")); - if (!["http:", "https:"].includes(discoveryUrl.protocol)) throw new Error("provider.discovery.url is invalid"); - return { - id, - name, - api: api as ProviderDefinition["api"], - connection: {type: "backend", baseUrl: baseUrl.toString().replace(/\/$/, ""), proxy}, - defaultModel, - auth: authType === "header" ? {type: authType, header} : {type: authType}, - headers: {}, - discovery: {type: discoveryType as ProviderDefinition["discovery"]["type"], url: discoveryUrl.toString()}, - builtin: false, - credentialState: "missing", - credentials: [] - }; -} - -async function catalogFor(identity: ChatIdentity) { - const response = await keyVaultFetch("/v1/providers", identity); - const payload = await response.json() as {providers?: ProviderDefinition[]; error?: string}; - if (!response.ok) throw Object.assign(new Error(payload.error || `Key Vault HTTP ${response.status}`), {statusCode: response.status}); - return payload.providers || []; -} - -async function savedSecret(identity: ChatIdentity, provider: ProviderDefinition, credentialName: string) { - if (!provider.credentials.some((credential) => credential.name === credentialName)) return null; - const response = await keyVaultFetch("/v1/resolve", identity, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({providerId: provider.id, credentialName}) - }); - const payload = await response.json() as ResolvedBackendProvider & {error?: string}; - if (!response.ok) throw Object.assign(new Error(payload.error || `Key Vault HTTP ${response.status}`), {statusCode: response.status}); - return payload.credential.secret; -} - -async function providerTest(request: Request) { - try { - const identity = identityFromHeaders(request.headers); - const input = await request.json() as {providerId?: string; credentialName?: string; provider?: unknown; secret?: ProviderSecret}; - const credentialName = input.credentialName?.trim() || "default"; - let provider: ProviderDefinition; - let secret: ProviderSecret = input.secret || {}; - if (input.provider) { - provider = temporaryProvider(input.provider); - if (provider.auth.type !== "none" && !secret.provider?.apiKey) { - const saved = (await catalogFor(identity)).find((item) => item.id === provider.id); - const existingSecret = saved ? await savedSecret(identity, saved, credentialName) : null; - if (!existingSecret) return json({error: "Temporary API Key is required for connectivity testing"}, 409); - secret = existingSecret; - } - } else { - const providerId = input.providerId?.trim(); - if (!providerId) return json({error: "providerId is required"}, 400); - const saved = (await catalogFor(identity)).find((item) => item.id === providerId); - if (!saved) return json({error: "Provider not found"}, 404); - provider = saved; - const existingSecret = await savedSecret(identity, provider, credentialName); - if (existingSecret) secret = existingSecret; - else if (provider.auth.type !== "none") return json({error: `Credential ${credentialName} is required for connectivity testing`}, 409); - } - const result = await testProviderConnectivity(provider, secret, createServerProviderFetch(provider, secret)); - return json({...result, detected: {id: provider.id, name: provider.name, api: provider.api, auth: provider.auth, connection: provider.connection, discovery: provider.discovery}}); - } catch (error) { - return json({error: error instanceof Error ? error.message : "Provider connectivity test failed"}, errorStatus(error, 502)); - } -} - -function streamEvent(controller: ReadableStreamDefaultController, event: unknown) { - controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); -} - -function cleanMessages(value: unknown): StoredChatMessage[] { - if (!Array.isArray(value)) throw new Error("messages are required"); - const timestamp = new Date().toISOString(); - let parentMessageId: string | null = null; - return value.map((message) => { - if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("message is invalid"); - const record = message as Partial; - const normalized: StoredChatMessage = { - id: typeof record.id === "string" ? record.id : randomUUID(), - parentMessageId: typeof record.parentMessageId === "string" ? record.parentMessageId : parentMessageId, - role: record.role as StoredChatMessage["role"], - parts: Array.isArray(record.parts) ? record.parts.filter((part) => part.type === "text" || part.type === "reasoning") : [], - origin: record.origin || {type: "legacy"}, - completion: record.completion || {status: "complete"}, - createdAt: record.createdAt || timestamp, - completedAt: record.completedAt || timestamp, - ...(record.metadata ? {metadata: record.metadata} : {}) - }; - parentMessageId = normalized.id; - return normalized; - }); -} - -function asTokenCount(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value); - if (typeof value === "bigint" && value >= 0n) return Number(value); - if (typeof value === "string") { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed >= 0) return Math.round(parsed); - } - return null; -} - -function extractOutputTokens(usage: unknown): number | undefined { - if (!usage || typeof usage !== "object") return; - const record = usage as Record; - const directCandidates = [ - "outputTokens", - "completionTokens", - "completion_tokens", - "output_tokens", - "responseTokens", - "generatedTokens", - "textGenerationTokens" - ]; - for (const key of directCandidates) { - const value = asTokenCount(record[key]); - if (value !== null) return value; - } - const total = asTokenCount(record.totalTokens) ?? asTokenCount(record.total_tokens) ?? asTokenCount(record.tokens); - const prompt = asTokenCount(record.promptTokens) ?? asTokenCount(record.prompt_tokens) ?? asTokenCount(record.inputTokens) ?? asTokenCount(record.input_tokens); - if (total !== null && prompt !== null) return Math.max(0, total - prompt); - return; -} - -function estimateOutputTokens(text: string): number { - const trimmed = text.trim(); - if (!trimmed) return 0; - const nonWhite = trimmed.replace(/\s+/g, ""); - const chineseChars = (nonWhite.match(/\p{Script=Han}/gu) || []).length; - const otherChars = nonWhite.length - chineseChars; - return Math.max(0, Math.round(chineseChars + otherChars / 4)); -} - -async function chat(request: Request) { - try { - const identity = identityFromHeaders(request.headers); - const input = await request.json() as { - messages?: unknown; - providerId?: string; - credentialName?: string; - model?: string; - conversationId?: string; - generationSettings?: unknown; - }; - const messages = cleanMessages(input.messages); - if (!messages.length) return json({error: "messages are required"}, 400); - if (!input.providerId?.trim() || !input.model?.trim() || !input.conversationId?.trim()) { - return json({error: "conversationId, providerId and model are required"}, 400); - } - const conversationId = input.conversationId.trim(); - const response = await keyVaultFetch("/v1/resolve", identity, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({providerId: input.providerId.trim(), credentialName: input.credentialName?.trim() || "default"}) - }); - const resolved = await response.json() as ResolvedBackendProvider & {error?: string}; - if (!response.ok) return json({error: resolved.error || `Key Vault HTTP ${response.status}`}, response.status); - if (resolved.provider.connection.type !== "backend") return json({error: "Frontend Provider must run in the browser"}, 409); - const providerFetch = createServerProviderFetch(resolved.provider, resolved.credential.secret); - const model = await createProviderModel(resolved.provider, resolved.credential.secret, input.model.trim(), providerFetch); - const generationSettings = normalizeGenerationSettings(input.generationSettings); - const startedAt = performance.now(); - const result = streamText({ - model, - messages: await convertToModelMessages(messages as UIMessage[]), - abortSignal: request.signal, - ...generationCallOptions(resolved.provider, generationSettings) - }); - const body = new ReadableStream({ - async start(controller) { - let text = ""; - let reasoning = ""; - try { - streamEvent(controller, {type: "start"}); - for await (const part of result.fullStream) { - if (part.type === "text-delta") { - text += part.text; - streamEvent(controller, {type: "text-delta", text: part.text}); - } else if (part.type === "reasoning-delta") { - reasoning += part.text; - streamEvent(controller, {type: "reasoning-delta", text: part.text}); - } else if (part.type === "error") { - throw part.error; - } - } - const usage = await result.usage; - const outputTokens = extractOutputTokens(usage); - const metadata = responseMetadata( - input.providerId!.trim(), - input.model!.trim(), - startedAt, - outputTokens, - outputTokens === undefined ? estimateOutputTokens(text) : undefined - ); - streamEvent(controller, {type: "finish", metadata}); - } catch (error) { - console.error("Backend Provider request failed", error instanceof Error ? error.message : error); - streamEvent(controller, {type: "error", error: error instanceof Error ? error.message : "Chat request failed"}); - } finally { - controller.close(); - } - } - }); - return new Response(body, { - headers: { - ...securityHeaders, - "Cache-Control": "no-store", - "Content-Type": "application/x-ndjson; charset=utf-8", - "X-Accel-Buffering": "no" - } - }); - } catch (error) { - console.error("Chat request failed", error instanceof Error ? error.message : error); - return json({error: error instanceof Error ? error.message : "Chat request failed"}, errorStatus(error, 500)); - } -} - -const mimeTypes: Record = { - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".ico": "image/x-icon", - ".js": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".svg": "image/svg+xml", - ".webmanifest": "application/manifest+json; charset=utf-8", - ".webp": "image/webp" -}; - -async function staticResponse(pathname: string) { - let decoded: string; - try { - decoded = decodeURIComponent(pathname); - } catch { - return json({error: "Invalid path"}, 400); - } - const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, ""); - let filePath = path.resolve(staticRoot, relative); - if (!filePath.startsWith(`${staticRoot}${path.sep}`) && filePath !== path.join(staticRoot, "index.html")) return json({error: "Not found"}, 404); - let file = Bun.file(filePath); - if (!(await file.exists()) && !path.extname(relative)) { - filePath = path.join(staticRoot, "index.html"); - file = Bun.file(filePath); - } - if (!(await file.exists())) return json({error: "Not found"}, 404); - const extension = path.extname(filePath); - const immutable = /-[A-Za-z0-9_-]{8,}\.(?:js|css)$/.test(path.basename(filePath)) - || relative.startsWith("assets/mathjax/4.1.3/"); - return new Response(file, { - headers: { - ...securityHeaders, - "Content-Type": mimeTypes[extension] || "application/octet-stream", - "Cache-Control": extension === ".html" || path.basename(filePath) === "sw.js" - ? "no-cache" - : immutable ? "public, max-age=31536000, immutable" : "public, max-age=3600" - } - }); -} - -const server = Bun.serve({ - port, - hostname: "0.0.0.0", - idleTimeout: 255, - async fetch(request, server) { - const url = new URL(request.url); - if (url.pathname === basePath) return Response.redirect(new URL(`${basePath}/${url.search}${url.hash}`, request.url), 308); - if (!url.pathname.startsWith(`${basePath}/`)) return json({error: "Not found"}, 404); - const pathname = url.pathname.slice(basePath.length) || "/"; - if (pathname === "/api/health" && request.method === "GET") return json({status: "ok"}); - if (pathname === "/api/public-config" && request.method === "GET") return publicConfig(); - if (pathname === "/api/login" && request.method === "GET") return Response.redirect(new URL(`${basePath}/`, request.url), 302); - if (pathname === "/api/config" && request.method === "GET") return config(request); - if (pathname === "/api/sync/fetch") return repositoryFetch(request); - if (pathname === "/api/sync/push") return repositoryPush(request); - if (pathname === "/api/conversations") return conversations(request); - const conversationMessageMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/messages$/); - if (conversationMessageMatch) return conversationMessage(request, decodeURIComponent(conversationMessageMatch[1])); - const conversationMatch = pathname.match(/^\/api\/conversations\/([^/]+)$/); - if (conversationMatch) return conversation(request, decodeURIComponent(conversationMatch[1])); - if (pathname === "/api/provider-test" && request.method === "POST") return providerTest(request); - if (pathname === "/api/chat" && request.method === "POST") { - server.timeout(request, 0); - return chat(request); - } - if (pathname.startsWith("/api/")) return json({error: "Not found"}, 404); - if (request.method !== "GET" && request.method !== "HEAD") return json({error: "Method not allowed"}, 405); - return staticResponse(pathname); - } -}); - -console.log(`xiteng-chat Bun server listening on ${server.url}`); diff --git a/chat/src/styles.css b/chat/src/styles.css deleted file mode 100644 index 0af319c..0000000 --- a/chat/src/styles.css +++ /dev/null @@ -1,426 +0,0 @@ -.ui-icon { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } - -:root { - color-scheme: light; - --bg: #f7f7f5; - --panel: rgba(255, 255, 255, 0.88); - --text: #171717; - --muted: #6f6f6a; - --line: rgba(23, 23, 23, 0.11); - --soft: #eeeeea; - --accent: #171717; - --accent-text: #ffffff; - --danger: #a3382d; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -* { box-sizing: border-box; } -html, body { width: 100%; height: 100%; margin: 0; } -body { overflow: hidden; background: var(--bg); color: var(--text); } -button, input, select { font: inherit; } -button, a { -webkit-tap-highlight-color: transparent; } - -.app-shell { position: relative; height: 100dvh; background: radial-gradient(circle at 50% -20%, #fff 0, var(--bg) 42%); } -.app-header { position: absolute; inset: 0 0 auto; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; height: 64px; align-items: center; padding: 0 22px; border-bottom: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); } -.header-leading { display: flex; align-items: center; gap: 8px; justify-self: start; } -.brand { display: flex; align-items: center; gap: 10px; font-weight: 680; letter-spacing: -0.02em; } -.welcome-mark { display: grid; place-items: center; color: #fff; background: #171717; font-weight: 800; letter-spacing: -0.08em; } -.portal-home-link { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 11px; transition: background .15s ease, transform .15s ease; } -.portal-home-link:hover { background: var(--soft); transform: translateY(-1px); } -.portal-home-link:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } -.portal-home-link img { display: block; width: 30px; height: 30px; border-radius: 7px; } -.chat-controls { display: flex; align-items: center; justify-self: end; gap: 8px; } -.identity-sync-control { display: inline-flex; height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 11px; padding: 4px 10px 4px 5px; background: rgba(255,255,255,.72); color: var(--muted); font-size: 10px; font-weight: 650; text-decoration: none; white-space: nowrap; cursor: pointer; } -.identity-sync-control:hover { border-color: rgba(23,23,23,.2); background: #fff; color: var(--text); } -.identity-sync-control:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } -.identity-sync-avatar { position: relative; display: grid; width: 26px; height: 26px; flex: 0 0 26px; place-items: center; } -.identity-sync-local { border-radius: 8px; background: var(--soft); color: var(--muted); } -.identity-sync-local .ui-icon { width: 15px; height: 15px; } -.header-avatar { width: 26px; height: 26px; border-radius: 8px; object-fit: cover; background: var(--soft); } -.identity-sync-status { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--panel); border-radius: 50%; background: #36a269; box-shadow: 0 0 0 1px rgba(54,162,105,.25); } -.identity-sync-control.local .identity-sync-status { background: #6f6f6a; box-shadow: 0 0 0 1px rgba(111,111,106,.25); } -.identity-sync-control.offline .identity-sync-status { background: var(--danger); box-shadow: 0 0 0 1px rgba(163,56,45,.25); } -.identity-sync-control.error .identity-sync-status { background: #d3932f; box-shadow: 0 0 0 1px rgba(211,147,47,.3); } -.identity-sync-control.fetching .identity-sync-status { border: 1px solid var(--muted); border-top-color: transparent; background: var(--panel); box-shadow: none; animation: spin .75s linear infinite; } -.model-provider-settings { display: grid; gap: 6px; } -.model-provider-settings > .settings-section-heading { padding: 0 2px 4px; } -.local-key-entry { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--line); border-radius: 11px; padding: 10px 11px; background: color-mix(in srgb, var(--panel) 70%, transparent); } -.local-key-entry:hover { background: var(--panel); } -.local-key-entry > span { min-width: 0; text-align: left; } -.local-key-entry > span strong, .local-key-entry > span small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.local-key-entry > span small { margin-top: 3px; color: var(--muted); font-size: 10px; } -.local-key-entry > span .local-key-error { max-width: 190px; color: var(--danger); white-space: normal; } -.local-key-entry > div { display: flex; flex: 0 0 auto; gap: 2px; } -.model-provider-settings button { border: 0; border-radius: 8px; padding: 8px 9px; background: var(--soft); color: var(--text); text-align: left; cursor: pointer; } -.model-provider-settings button:hover { background: color-mix(in srgb, var(--soft) 76%, var(--text)); } -.model-provider-settings button.dangerous { color: #b42318; } -.model-picker { position: relative; } -.model-picker > summary { display: flex; min-width: 190px; max-width: 290px; height: 36px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); border-radius: 11px; padding: 0 10px; background: rgba(255,255,255,.72); color: var(--text); cursor: pointer; list-style: none; } -.model-picker > summary .picker-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.picker-icons { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 4px; color: var(--muted); } -.picker-icons > svg:first-child { width: 15px; height: 15px; } -.picker-chevron { display: inline-flex; transition: transform .15s ease; } -.model-picker > summary::-webkit-details-marker { display: none; } -.model-picker[open] > summary { border-color: rgba(23,23,23,.24); background: #fff; } -.model-picker[open] > summary .picker-chevron { transform: rotate(180deg); } -.model-menu { position: fixed; z-index: 30; top: 56px; right: 22px; display: grid; width: min(360px, calc(100vw - 24px)); max-height: min(520px, calc(100dvh - 82px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 15px; padding: 8px; background: var(--panel); box-shadow: 0 18px 48px rgba(0,0,0,.15); backdrop-filter: blur(18px); } -.quick-models { min-height: 0; } -.quick-config-heading, .effort-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 7px 8px; } -.quick-config-heading strong, .effort-heading strong { font-size: 11px; } -.quick-config-heading small, .effort-heading small { color: var(--muted); font-size: 9px; } -.quick-model-list { display: grid; max-height: 238px; overflow-y: auto; gap: 2px; } -.model-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; border: 0; border-radius: 8px; padding: 8px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } -.model-option:hover, .model-option.active { background: var(--soft); } -.model-option > span { min-width: 0; } -.model-option strong, .model-option small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.model-option strong { font-size: 12px; font-weight: 620; } -.model-option small { color: var(--muted); font-size: 10px; } -.model-option.active::after { flex: 0 0 auto; color: var(--text); content: "✓"; font-size: 11px; font-weight: 800; } -.model-option.active > small { display: none; } -.effort-control { display: grid; gap: 3px; margin-top: 7px; border-top: 1px solid var(--line); padding-top: 5px; } -.effort-options { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 3px; padding: 0 3px 6px; } -.effort-options label { min-width: 0; cursor: pointer; } -.effort-options input { position: absolute; opacity: 0; pointer-events: none; } -.effort-options span { display: grid; height: 30px; place-items: center; border-radius: 8px; color: var(--muted); font-size: 10px; } -.effort-options input:checked + span { background: var(--soft); color: var(--text); font-weight: 700; } -.effort-options input:focus-visible + span { outline: 2px solid var(--text); outline-offset: 1px; } -.open-settings-button { display: grid; width: 100%; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 9px; margin-top: 3px; border: 0; border-top: 1px solid var(--line); padding: 11px 8px 6px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } -.open-settings-button:hover { background: var(--soft); } -.open-settings-button .ui-icon { color: var(--muted); } -.open-settings-button strong, .open-settings-button small { display: block; } -.open-settings-button strong { font-size: 11px; } -.open-settings-button small { margin-top: 2px; color: var(--muted); font-size: 9px; } -.settings-section-heading { display: grid; gap: 2px; margin-bottom: 2px; } -.settings-section-heading strong { font-size: 12px; } -.settings-section-heading small { color: var(--muted); font-size: 9px; } -.settings-page { position: fixed; z-index: 90; inset: 0; display: flex; flex-direction: column; background: var(--bg); } -.settings-page-header { display: flex; height: 64px; flex: 0 0 auto; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 22px; background: var(--panel); } -.settings-page-header button { display: grid; width: 34px; height: 34px; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--text); cursor: pointer; } -.settings-page-header button:hover { background: var(--soft); } -.settings-page-header > span strong, .settings-page-header > span small { display: block; } -.settings-page-header > span strong { font-size: 15px; } -.settings-page-header > span small { margin-top: 2px; color: var(--muted); font-size: 9px; } -.settings-layout { display: grid; min-height: 0; flex: 1; grid-template-columns: 176px minmax(0, 760px); justify-content: center; gap: 28px; overflow: hidden; padding: 28px 24px; } -.settings-nav { display: grid; align-content: start; gap: 3px; } -.settings-nav button { border: 0; border-radius: 9px; padding: 9px 11px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 650; text-align: left; cursor: pointer; } -.settings-nav button:hover { background: var(--soft); color: var(--text); } -.settings-content { display: grid; min-width: 0; align-content: start; overflow-y: auto; gap: 16px; padding: 0 8px 40px 0; scroll-behavior: smooth; } -.settings-card { border: 1px solid var(--line); border-radius: 16px; padding: 18px; background: var(--panel); scroll-margin-top: 12px; } -.settings-card > header { margin-bottom: 16px; } -.settings-card h2, .settings-card h3, .settings-card p { margin: 0; } -.settings-card h2 { font-size: 16px; letter-spacing: -.02em; } -.settings-card > header p { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; } -.settings-model-search { display: flex; height: 40px; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 10px; padding: 0 11px; background: var(--bg); color: var(--muted); } -.settings-model-search input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); } -.settings-model-groups { display: grid; gap: 13px; margin-top: 13px; } -.settings-model-group { display: grid; gap: 5px; } -.settings-model-group h3 { padding: 0 8px; color: var(--muted); font-size: 10px; } -.settings-model-group > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; } -.settings-model-group .model-option { border: 1px solid transparent; } -.settings-model-group .model-option.active { border-color: var(--line); } -.settings-empty { color: var(--muted); font-size: 11px; text-align: center; } -.settings-card > .effort-control { margin: 0; border: 0; padding: 0; } -.settings-card .effort-heading { padding: 0 0 8px; } -.settings-card .effort-options { max-width: 440px; padding: 0; } -.settings-field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(160px, .65fr) minmax(160px, .65fr); gap: 10px; margin-top: 18px; } -.settings-field-grid > label:not(.settings-check) { display: grid; align-content: start; gap: 6px; color: var(--muted); font-size: 10px; } -.settings-field-grid input[type="number"] { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 9px; padding: 9px; background: var(--bg); color: var(--text); } -.settings-check { display: flex; min-width: 0; align-items: flex-start; gap: 9px; border-radius: 10px; padding: 9px; color: var(--text); cursor: pointer; } -.settings-check:hover { background: var(--soft); } -.settings-check input { margin-top: 2px; } -.settings-check span { min-width: 0; } -.settings-check strong, .settings-check small { display: block; } -.settings-check strong { font-size: 11px; } -.settings-check small { margin-top: 3px; color: var(--muted); font-size: 9px; line-height: 1.45; } -.settings-reset-button { margin-top: 14px; border: 0; border-radius: 9px; padding: 9px 11px; background: var(--soft); color: var(--text); cursor: pointer; } -.settings-account-link { display: flex; align-items: center; gap: 9px; margin-top: 12px; border: 1px solid var(--line); border-radius: 11px; padding: 11px; color: var(--text); font-size: 11px; font-weight: 650; text-decoration: none; } -.settings-account-link:hover { background: var(--soft); } -.settings-account-link .ui-icon { color: var(--muted); } -.settings-interface-options { display: grid; gap: 4px; } -.history-toggle, .history-heading button, .history-delete { display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; } -.history-toggle { width: 32px; height: 32px; border-radius: 9px; } -.history-toggle:hover, .history-heading button:hover, .history-delete:hover { background: var(--soft); } -.history-sidebar { position: absolute; z-index: 9; top: 64px; bottom: 0; left: 0; width: 252px; display: flex; flex-direction: column; transform: translateX(-102%); border-right: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); transition: transform .18s ease; } -.history-sidebar.open { transform: translateX(0); } -.history-heading { display: flex; height: 52px; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 0 12px 0 16px; border-bottom: 1px solid var(--line); font-size: 13px; } -.history-heading > div { display: flex; gap: 2px; } -.history-heading button { width: 30px; height: 30px; border-radius: 8px; } -.history-transfer { position: relative; } -.history-transfer > summary { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 8px; cursor: pointer; list-style: none; } -.history-transfer > summary::-webkit-details-marker { display: none; } -.history-transfer > summary:hover, .history-transfer[open] > summary { background: var(--soft); } -.history-transfer-menu { position: absolute; z-index: 45; top: 36px; right: -65px; display: grid; width: 248px; gap: 2px; border: 1px solid var(--line); border-radius: 12px; padding: 7px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); } -.history-transfer-menu button { display: grid; width: 100%; height: auto; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: center; padding: 8px; text-align: left; } -.history-transfer-menu button > span:last-child { min-width: 0; } -.history-transfer-menu strong, .history-transfer-menu small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.history-transfer-menu strong { font-size: 11px; } -.history-transfer-menu small { margin-top: 2px; color: var(--muted); font-size: 9px; } -.history-transfer-menu hr { width: 100%; margin: 3px 0; border: 0; border-top: 1px solid var(--line); } -.format-mark { display: grid; width: 25px; height: 25px; place-items: center; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); font-size: 8px; font-weight: 800; letter-spacing: .04em; } -.session-import-overlay { position: fixed; z-index: 80; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.42); } -.session-import-panel { width: min(620px, 100%); max-height: min(760px, calc(100dvh - 40px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 18px; padding: 18px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.25); backdrop-filter: blur(22px); } -.session-import-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } -.session-import-panel h2, .session-import-panel h3, .session-import-panel p { margin: 0; } -.session-import-panel h2 { font-size: 17px; } -.session-import-panel header p { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; } -.session-import-panel header button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text); cursor: pointer; } -.session-import-panel header button:hover { background: var(--soft); } -.session-location-help { margin-top: 16px; border: 1px solid var(--line); border-radius: 13px; padding: 13px; background: color-mix(in srgb, var(--soft) 56%, transparent); } -.session-location-help h3 { font-size: 11px; } -.session-location-help dl { display: grid; gap: 7px; margin: 10px 0; } -.session-location-help dl > div { display: grid; min-width: 0; grid-template-columns: 88px minmax(0, 1fr); gap: 8px; align-items: baseline; } -.session-location-help dt { font-size: 10px; font-weight: 700; } -.session-location-help dd { min-width: 0; margin: 0; color: var(--muted); font-size: 10px; } -.session-location-help code { overflow-wrap: anywhere; color: var(--text); font-size: 9px; } -.session-location-help > p { color: var(--muted); font-size: 9px; line-height: 1.55; } -.session-title-template { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; margin-top: 12px; } -.session-title-template > span strong, .session-title-template > span small { display: block; } -.session-title-template > span strong { font-size: 10px; } -.session-title-template > span small { margin-top: 3px; color: var(--muted); font-size: 8px; } -.session-title-template input { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px; outline: 0; background: var(--bg); color: var(--text); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } -.session-title-template input:focus { border-color: color-mix(in srgb, var(--text) 34%, var(--line)); } -.session-title-template input[aria-invalid="true"] { border-color: var(--danger); } -.session-title-template-help { margin-top: 7px !important; color: var(--muted); font-size: 8px; line-height: 1.55; } -.session-title-template-help code { color: var(--text); } -.session-title-template-preview { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--muted); font-size: 9px; } -.session-title-template-preview.error { color: var(--danger); } -.session-import-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; } -.session-import-actions button { display: grid; min-width: 0; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: center; border: 1px solid var(--line); border-radius: 12px; padding: 10px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } -.session-import-actions button:hover { border-color: rgba(23,23,23,.22); background: var(--soft); } -.session-import-actions button:disabled { cursor: wait; opacity: .55; } -.session-import-actions button > span:last-child { min-width: 0; } -.session-import-actions strong, .session-import-actions small { display: block; } -.session-import-actions strong { font-size: 10px; } -.session-import-actions small { margin-top: 3px; color: var(--muted); font-size: 8px; line-height: 1.4; } -.session-import-status { margin: 12px 0 0; border-radius: 10px; padding: 10px; overflow-wrap: anywhere; white-space: pre-wrap; background: var(--soft); color: var(--muted); font: 9px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } -.history-close { display: none !important; } -.history-list { min-height: 0; flex: 1; overflow-y: auto; padding: 8px; } -.history-item { display: grid; grid-template-columns: minmax(0, 1fr) 28px 28px; align-items: center; border-radius: 9px; padding-left: calc(var(--history-depth, 0) * 12px); } -.history-item:hover, .history-item.active { background: var(--soft); } -.history-select { min-width: 0; border: 0; padding: 9px 4px 9px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } -.history-select strong, .history-select small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.history-select strong { font-size: 12px; font-weight: 650; } -.history-select small { margin-top: 3px; color: var(--muted); font-size: 9px; } -.history-delete, .history-rename { display: grid; width: 26px; height: 26px; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; opacity: 0; } -.history-item:hover .history-delete, .history-item.active .history-delete, .history-item:hover .history-rename, .history-item.active .history-rename { opacity: 1; } -.history-delete:hover, .history-rename:hover { background: var(--panel); color: var(--text); } -.history-folder { overflow: hidden; margin-top: 5px; padding: 6px 8px 3px calc(9px + var(--history-depth, 0) * 12px); color: var(--muted); font-size: 10px; font-weight: 750; letter-spacing: .06em; text-overflow: ellipsis; white-space: nowrap; } -.history-backdrop { display: none; } -.with-history .thread-root { padding-left: 0; transition: padding-left .18s ease; } -.with-history.history-open .thread-root { padding-left: 252px; } - -.thread-root { height: 100dvh; padding-top: 64px; } -.thread-viewport { position: relative; display: flex; height: 100%; flex-direction: column; overflow-y: auto; overflow-anchor: none; padding: 24px 18px 0; } -#message-list { display: flex; flex: 1 0 auto; flex-direction: column; } -.welcome { display: flex; width: min(680px, 100%); flex: 1; flex-direction: column; justify-content: center; margin: 0 auto; padding: 80px 4px 210px; } -.welcome-mark { width: 48px; height: 48px; border-radius: 16px; font-size: 15px; box-shadow: 0 10px 30px rgba(0,0,0,.12); } -.welcome h1 { margin: 22px 0 8px; font-size: clamp(30px, 5vw, 46px); line-height: 1.08; letter-spacing: -.045em; } -.welcome p { max-width: 560px; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.7; } -.message { width: min(760px, 100%); scroll-margin-block: 88px 180px; margin: 0 auto; padding: 14px 0; } -.user-message { display: grid; justify-items: end; } -.user-message-actions { display: flex; height: 30px; align-items: center; gap: 3px; margin: 2px 4px -8px 0; color: var(--muted); } -.message-content { overflow-wrap: anywhere; font-size: 15px; line-height: 1.72; } -.user-content { max-width: min(620px, 86%); border-radius: 16px 16px 5px 16px; padding: 7px 13px; background: var(--soft); line-height: 1.5; white-space: pre-wrap; } -.user-content > p { margin: 0; } -.user-content > p + p { margin-top: 6px; } -.user-message.reply-target .user-content { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent); } -.assistant-content { padding: 2px 4px; } -.assistant-message.reply-target .assistant-content { border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--muted)); border-radius: 0 9px 9px 0; padding: 6px 10px; background: color-mix(in srgb, var(--soft) 58%, transparent); } -.message.reply-target-pulse .message-content { animation: reply-target-pulse .9s ease; } -.assistant-content .aui-md { color: var(--text); } -.assistant-content .aui-md > .markdown-block:first-child > :first-child { margin-top: 0; } -.assistant-content .aui-md > .markdown-block:last-child > :last-child { margin-bottom: 0; } -.assistant-content .markdown-block:empty { display: none; } -.assistant-content .markdown-block[data-math-pending="1"] { min-height: 1.72em; } -.assistant-content .math-fragment { display: contents; } -.assistant-content .aui-md mjx-container[display="true"] { display: block; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: .15em 0; } -.assistant-content .aui-md mjx-container[display="true"] > svg { max-width: none; } -.assistant-content pre { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: #20201f; color: #f6f6f3; } -.assistant-content code { font-family: "SFMono-Regular", Consolas, monospace; font-size: .88em; } -.assistant-content :not(pre) > code { border-radius: 5px; padding: 2px 5px; background: var(--soft); } -.assistant-content a { color: inherit; text-underline-offset: 3px; } -.message-reasoning { margin: 0 0 12px; border-left: 2px solid var(--line); padding-left: 12px; color: var(--muted); } -.message-reasoning > summary { cursor: pointer; font-size: 12px; font-weight: 650; } -.message-reasoning > div { margin-top: 7px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; } -.message-error { margin-top: 10px; border: 1px solid rgba(163,56,45,.2); border-radius: 10px; padding: 10px 12px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 13px; } -.partial-badge { display: inline-flex; max-width: min(240px, 40vw); flex: 0 0 auto; overflow: hidden; border: 1px solid rgba(163,56,45,.22); border-radius: 999px; padding: 3px 8px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } -.thread-footer { position: sticky; bottom: 0; display: flex; width: min(760px, 100%); flex-direction: column; align-items: center; margin: auto auto 0; padding: 40px 0 16px; background: linear-gradient(to bottom, transparent, var(--bg) 38%); } -.thread-footer:has(.model-picker[open]) { z-index: 50; } -.edit-context { display: flex; width: calc(100% - 24px); align-items: center; justify-content: space-between; gap: 12px; margin: 0 12px -1px; border: 1px solid var(--line); border-bottom: 0; border-radius: 12px 12px 0 0; padding: 8px 12px 9px; background: var(--soft); color: var(--muted); font-size: 11px; } -.edit-context button { border: 0; background: transparent; color: var(--text); font: inherit; font-weight: 650; cursor: pointer; } -.edit-context + .composer { border-top-left-radius: 10px; border-top-right-radius: 10px; } -.composer-controls { display: flex; width: 100%; align-items: center; gap: 8px; padding: 0 4px 7px; color: var(--muted); font-size: 10px; } -.composer-controls:empty { display: none; } -.composer-controls label { display: flex; min-width: 0; align-items: center; gap: 5px; white-space: nowrap; } -.composer-controls select { min-width: 0; max-width: 230px; border: 1px solid var(--line); border-radius: 8px; padding: 5px 7px; background: var(--panel); color: var(--text); font: inherit; } -.reply-context { display: flex; min-width: 0; max-width: min(520px, 100%); align-items: center; gap: 5px; } -.reply-context > span { flex: 0 0 auto; } -.reply-context > .reply-context-icon { display: grid; width: 22px; height: 22px; place-items: center; color: var(--muted); } -.reply-context-icon .ui-icon { width: 15px; height: 15px; } -.reply-context > button { min-width: 0; overflow: hidden; border: 0; border-radius: 7px; padding: 5px 7px; background: var(--soft); color: var(--text); font: inherit; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } -.reply-context > button:hover { background: color-mix(in srgb, var(--soft) 76%, var(--text)); } -.reply-context > .reply-cancel { display: grid; width: 25px; height: 25px; flex: 0 0 auto; place-items: center; padding: 0; color: var(--muted); } -.branch-navigator { display: inline-flex; align-items: center; gap: 1px; color: var(--muted); font-size: 10px; white-space: nowrap; } -.branch-navigator .icon-button { width: 24px; height: 24px; font-size: 17px; } -.branch-preview-notice { position: relative; z-index: 5; display: flex; width: min(760px, 100%); flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; margin: 0 auto 10px; border: 1px solid rgba(211,147,47,.28); border-radius: 11px; padding: 8px 10px; background: color-mix(in srgb, var(--panel) 92%, #d3932f 8%); color: var(--muted); font-size: 11px; } -.branch-preview-notice > div { display: flex; gap: 5px; } -.branch-preview-notice button { border: 0; border-radius: 7px; padding: 6px 8px; background: var(--soft); color: var(--text); font: inherit; cursor: pointer; } -.scroll-button { position: absolute; top: 1px; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); box-shadow: 0 5px 18px rgba(0,0,0,.08); cursor: pointer; } -.message-footer { display: flex; min-height: 30px; align-items: center; justify-content: space-between; gap: 12px; margin: 6px 0 0; } -.response-summary { display: flex; min-width: 0; align-items: center; gap: 8px; } -.response-meta { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--muted); font-size: 10px; } -.response-meta span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.response-meta span + span::before { margin-right: 7px; content: "·"; } -.scroll-button:disabled { visibility: hidden; } -.composer { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: end; gap: 8px; border: 1px solid rgba(23,23,23,.15); border-radius: 20px; padding: 8px 8px 8px 16px; background: #fff; box-shadow: 0 14px 45px rgba(0,0,0,.09); } -.composer:focus-within { border-color: rgba(23,23,23,.32); box-shadow: 0 14px 45px rgba(0,0,0,.1), 0 0 0 3px rgba(23,23,23,.04); } -.composer > .model-picker { align-self: end; } -.composer > .model-picker > summary { min-width: 0; width: auto; max-width: 170px; height: 38px; border: 0; border-radius: 12px; padding: 0 9px; background: var(--soft); font-size: 10px; } -.composer > .model-picker > summary .picker-label { max-width: 132px; } -.composer > .model-picker .model-menu { top: auto; right: max(12px, calc((100vw - 760px) / 2)); bottom: 84px; } -.message-actions { display: flex; flex: 0 0 auto; gap: 2px; margin: 0; opacity: 1; visibility: visible; } -.icon-button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; } -.icon-button:hover { background: var(--soft); color: var(--text); } -.composer-input { min-width: 0; width: 100%; max-height: 180px; min-height: 38px; resize: none; border: 0; outline: 0; padding: 9px 0 7px; background: transparent; color: var(--text); line-height: 1.5; } -.composer-input:disabled { cursor: not-allowed; color: var(--muted); } -.composer-input::placeholder { color: #9a9a94; } -.composer-actions { display: flex; align-items: center; gap: 4px; } -.send-button, .stop-button, .fullscreen-button { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 13px; cursor: pointer; } -.send-button { background: var(--accent); color: var(--accent-text); } -.stop-button, .fullscreen-button { border: 1px solid var(--line); background: var(--panel); color: var(--text); } -.fullscreen-button:hover { background: var(--soft); } -.send-button:disabled { cursor: default; opacity: .35; } -.composer-note { margin: 8px 0 0; color: #90908a; font-size: 11px; } -.composer-note.offline { color: var(--danger); } -.composer-note.queued { color: var(--text); } -.working-panel { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: flex-end; gap: 6px; margin-bottom: 7px; } -.working-panel .assistant-reply-toggle { display: flex; min-width: 0; align-items: center; gap: 5px; margin-right: auto; color: var(--text); font-size: 10px; white-space: nowrap; } -.working-panel .assistant-reply-toggle input { margin: 0; } -.working-panel .composer-controls { width: auto; min-width: 0; flex: 1 1 auto; justify-content: flex-end; padding: 0; overflow: hidden; } -.working-panel .reply-context { max-width: 100%; } -.working-panel details { position: relative; } -.working-panel summary { display: flex; height: 28px; align-items: center; border: 1px solid var(--line); border-radius: 9px; padding: 0 9px; background: var(--panel); color: var(--muted); font-size: 10px; cursor: pointer; list-style: none; } -.working-panel summary::-webkit-details-marker { display: none; } -.working-panel details > div { position: absolute; z-index: 20; right: 0; bottom: 34px; display: grid; width: min(380px, calc(100vw - 24px)); max-height: 300px; overflow-y: auto; gap: 3px; border: 1px solid var(--line); border-radius: 12px; padding: 7px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); backdrop-filter: blur(18px); } -.new-draft { display: flex; align-items: center; gap: 7px; border: 0; border-radius: 8px; padding: 8px 9px; background: transparent; color: var(--text); cursor: pointer; } -.new-draft:hover { background: var(--soft); } -.draft-row { display: grid; grid-template-columns: minmax(0, 1fr) 30px; border-radius: 8px; } -.draft-row.active, .draft-row:hover { background: var(--soft); } -.draft-row > button, .unfinished-row button { border: 0; border-radius: 7px; padding: 7px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } -.draft-row > button:last-child { display: grid; width: 30px; place-items: center; padding: 0; color: var(--muted); } -.draft-row strong, .draft-row small, .unfinished-row strong, .unfinished-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.draft-row strong, .unfinished-row strong { font-size: 11px; } -.draft-row small, .unfinished-row small { margin-top: 2px; color: var(--muted); font-size: 9px; } -.unfinished-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 3px; border-radius: 8px; padding: 6px 4px 6px 9px; } -.unfinished-row:hover { background: var(--soft); } -.unfinished-row > span { min-width: 0; } -.unfinished-row button { padding: 7px; font-size: 10px; } - -.thread-footer.fullscreen-editor { position: fixed; z-index: 70; inset: 0; bottom: auto; width: auto; height: 100dvh; align-items: stretch; margin: 0; padding: max(16px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); background: var(--bg); } -.fullscreen-editor-header { display: flex; min-height: 46px; flex: 0 0 auto; align-items: flex-start; justify-content: space-between; gap: 16px; } -.fullscreen-editor-header > span { display: grid; gap: 3px; } -.fullscreen-editor-header strong { font-size: 14px; } -.fullscreen-editor-header small { color: var(--muted); font-size: 10px; } -.fullscreen-editor-header button { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--text); cursor: pointer; } -.fullscreen-editor-header button:hover { background: var(--soft); } -.fullscreen-editor .scroll-button { display: none; } -.fullscreen-editor .working-panel { flex: 0 0 auto; } -.fullscreen-editor .edit-context, .fullscreen-editor .composer-controls { flex: 0 0 auto; } -.fullscreen-editor .composer { min-height: 0; flex: 1; grid-template-rows: minmax(0, 1fr) auto; border-radius: 16px; padding: 12px; } -.fullscreen-editor .composer-input { width: 100%; height: 100% !important; max-height: none; min-height: 0; grid-column: 1 / -1; grid-row: 1; align-self: stretch; padding: 10px 4px; resize: none; font-size: 15px; line-height: 1.65; } -.fullscreen-editor .composer-actions { grid-column: 2; grid-row: 2; } -.fullscreen-editor .composer > .model-picker { grid-column: 3; grid-row: 2; } -.fullscreen-editor .send-button { grid-column: 4; grid-row: 2; } -.fullscreen-editor .composer > .model-picker .model-menu { top: auto; right: max(18px, env(safe-area-inset-right)); bottom: max(76px, calc(env(safe-area-inset-bottom) + 70px)); } -.fullscreen-editor .composer-note { flex: 0 0 auto; align-self: center; } - -.state-page { position: relative; display: grid; min-height: 100dvh; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 10%, #fff, var(--bg) 52%); } -.state-card { width: min(520px, 100%); text-align: center; } -.state-provider-select { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 11px; padding: 8px 10px; color: var(--muted); background: var(--panel); } -.state-provider-select select { border: 0; outline: 0; color: var(--text); background: transparent; } -.state-mark { display: grid; width: 52px; height: 52px; place-items: center; margin: 0 auto 18px; border-radius: 17px; background: #171717; color: #fff; font-weight: 800; } -.state-card h1 { margin: 0 0 10px; font-size: 28px; letter-spacing: -.035em; } -.state-card p { margin: 0 auto; color: var(--muted); line-height: 1.7; } -.state-card code { border-radius: 5px; padding: 2px 5px; background: var(--soft); color: var(--text); } -.primary-link { display: inline-flex; margin-top: 22px; border-radius: 12px; padding: 11px 16px; background: #171717; color: #fff; text-decoration: none; } -.button-link { border: 0; cursor: pointer; } -.loader { display: block; width: 30px; height: 30px; margin: 0 auto 16px; border: 3px solid var(--line); border-top-color: #171717; border-radius: 50%; animation: spin .75s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } -.response-loader { display: inline-block; width: 7px; height: 18px; border-radius: 2px; background: currentColor; vertical-align: text-bottom; animation: pulse 1s ease-in-out infinite; } -.icon-button.copied { color: #25824d; } -.icon-button:disabled { cursor: default; opacity: .4; } -@keyframes pulse { 50% { opacity: .25; } } -@keyframes reply-target-pulse { 0%, 100% { filter: none; } 38% { filter: brightness(.82); } } - -@media (max-width: 680px) { - .app-header { grid-template-columns: auto auto 1fr; height: 58px; gap: 10px; padding: 0 12px; } - .thread-root { padding-top: 58px; } - .chat-controls { min-width: 0; gap: 5px; } - .chat-controls > * { flex: 0 0 auto; } - .model-picker > summary { min-width: 0; width: min(132px, 34vw); } - .app-header { overflow: clip; backdrop-filter: none; } - .identity-sync-control { width: 36px; justify-content: center; padding: 4px; } - .identity-sync-label { display: none; } - .provider-control select { max-width: 105px; } - .model-picker > summary { min-width: 132px; max-width: 170px; } - .picker-icons > svg:first-child { display: none; } - .model-menu { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); overflow-y: auto; } - .history-sidebar { position: fixed; z-index: 40; top: 0; width: min(300px, 86vw); border-right: 1px solid var(--line); } - .history-heading { height: 58px; } - .history-close { display: grid !important; } - .session-import-overlay { place-items: end center; padding: 0; } - .session-import-panel { width: 100%; max-height: calc(100dvh - 24px); border-width: 1px 0 0; border-radius: 18px 18px 0 0; padding: 16px 14px max(16px, env(safe-area-inset-bottom)); backdrop-filter: none; } - .session-location-help dl > div { grid-template-columns: 76px minmax(0, 1fr); } - .session-title-template { grid-template-columns: 1fr; gap: 6px; } - .session-import-actions { grid-template-columns: 1fr; } - .settings-page-header { height: 58px; padding: 0 12px; } - .settings-layout { display: flex; flex-direction: column; gap: 12px; padding: 12px 0 0; } - .settings-nav { display: flex; flex: 0 0 auto; gap: 2px; overflow-x: auto; padding: 0 12px; } - .settings-nav button { flex: 0 0 auto; padding: 8px 10px; } - .settings-content { gap: 12px; padding: 0 12px max(28px, env(safe-area-inset-bottom)); } - .settings-card { border-radius: 13px; padding: 14px; } - .settings-model-group > div { grid-template-columns: 1fr; } - .settings-field-grid { grid-template-columns: 1fr; } - .local-key-entry { align-items: flex-start; flex-direction: column; } - .local-key-entry > div { width: 100%; } - .history-backdrop { position: fixed; z-index: 35; inset: 0; display: block; border: 0; background: rgba(0,0,0,.36); opacity: 0; pointer-events: none; transition: opacity .18s ease; } - .history-backdrop.open { opacity: 1; pointer-events: auto; } - .with-history.history-open .thread-root { padding-left: 0; } - .thread-viewport { padding-inline: 12px; } - .welcome { padding-bottom: 170px; } - .user-content { max-width: 92%; } - .thread-footer { padding-bottom: max(10px, env(safe-area-inset-bottom)); } - .thread-footer.fullscreen-editor { padding: max(10px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); } - .fullscreen-editor-header { min-height: 42px; } - .fullscreen-editor .working-panel { margin-bottom: 5px; } - .fullscreen-editor .composer { padding: 9px; } - .fullscreen-editor .composer-input { padding: 8px 3px; font-size: 16px; } - .fullscreen-editor .composer > .model-picker .model-menu { right: 12px; left: 12px; } - .composer-note { display: none; } - .composer-controls { overflow-x: auto; padding-inline: 0; } - .composer-controls select { max-width: 170px; } - .composer > .model-picker > summary { width: auto; min-width: 0; max-width: 116px; } - .composer > .model-picker > summary .picker-label { max-width: 86px; } - .composer > .model-picker .model-menu { top: auto; right: 12px; bottom: max(76px, calc(env(safe-area-inset-bottom) + 70px)); left: 12px; max-height: min(520px, calc(100dvh - 152px - env(safe-area-inset-bottom))); } - .branch-preview-notice { flex-direction: column; align-items: stretch; gap: 8px; margin-bottom: 6px; } - .branch-preview-notice > div { display: grid; width: 100%; grid-template-columns: auto minmax(0, 1fr); } - .branch-preview-notice button { min-width: 0; } -} - -@media (max-width: 360px) { - .composer-actions:has(.stop-button) .fullscreen-button { display: none; } - .composer > .model-picker > summary { max-width: 96px; } - .composer > .model-picker > summary .picker-label { max-width: 68px; } -} - -@media (prefers-color-scheme: dark) { - :root { color-scheme: dark; --bg: #111210; --panel: rgba(22,23,21,.88); --text: #f2f2ee; --muted: #a1a19a; --line: rgba(255,255,255,.12); --soft: #242521; --accent: #f0f0ec; --accent-text: #171717; } - .app-shell { background: radial-gradient(circle at 50% -20%, #272824 0, var(--bg) 42%); } - .identity-sync-control, .model-picker > summary, .model-menu, .composer, .history-sidebar { background: rgba(31,32,29,.9); } - .model-picker[open] > summary { border-color: rgba(255,255,255,.24); background: #292a26; } - .identity-sync-control:hover { border-color: rgba(255,255,255,.22); background: #292a26; } - .assistant-content pre { background: #080908; } - .state-page { background: radial-gradient(circle at 50% 10%, #272824, var(--bg) 52%); } - .welcome-mark, .state-mark, .primary-link { background: #efefeb; color: #171717; } - .loader { border-top-color: #efefeb; } -} diff --git a/chat/tsconfig.json b/chat/tsconfig.json deleted file mode 100644 index c4f573e..0000000 --- a/chat/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "types": ["bun"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "allowJs": false, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true - }, - "include": ["src/**/*.ts", "lib/**/*.ts", "build.ts"], - "exclude": ["node_modules", "dist"] -}