refactor(chat): split Turnfold into standalone repository

This commit is contained in:
2026-08-13 22:59:29 +08:00
parent b2298e8b55
commit 3018b2159d
64 changed files with 35 additions and 8075 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "chat/app"]
path = chat/app
url = https://github.com/liooil/turnfold.git
-7
View File
@@ -1,7 +0,0 @@
node_modules
dist
.next
package-lock.json
npm-debug.log
data
run
-25
View File
@@ -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"]
+20
View File
@@ -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.
Submodule
+1
Submodule chat/app added at fe261e8f07
-25
View File
@@ -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});
-147
View File
@@ -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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
"@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "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/[email protected]", "", {}, "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@workflow/serde": ["@workflow/[email protected]", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"agent-base": ["[email protected]", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
"ai": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"basic-ftp": ["[email protected]", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["[email protected]", "", { "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": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="],
"escodegen": ["[email protected]", "", { "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": ["[email protected]", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["[email protected]", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventsource-parser": ["[email protected]", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"fetch-blob": ["[email protected]", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["[email protected]", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"formdata-polyfill": ["[email protected]", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"get-uri": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="],
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="],
"ip-address": ["[email protected]", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
"json-schema": ["[email protected]", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"lru-cache": ["[email protected]", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"mathjax": ["[email protected]", "", { "dependencies": { "@mathjax/mathjax-newcm-font": "^4.1.3" } }, "sha512-BN/8Pkgn7G1pIDYJqd9md+JHsE/jydSYbyOZnSdSA0WziuVO8mRxdYiWFumkVVly/8U+hm9DpIIoWuvySverzw=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"netmask": ["[email protected]", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"node-domexception": ["[email protected]", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "degenerator": "7.0.1", "netmask": "^2.0.2" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw=="],
"proxy-agent": ["[email protected]", "", { "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": ["[email protected]", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="],
"proxy-from-env": ["[email protected]", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"quickjs-wasi": ["[email protected]", "", {}, "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw=="],
"smart-buffer": ["[email protected]", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["[email protected]", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"socks-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ=="],
"source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["[email protected]", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"web-streams-polyfill": ["[email protected]", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"zod": ["[email protected]", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"get-uri/data-uri-to-buffer": ["[email protected]", "", {}, "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ=="],
}
}
+11 -4
View File
@@ -85,15 +85,22 @@ services:
chat: chat:
build: build:
context: . context: ./app
image: xiteng-chat:local args:
BASE_PATH: /chat
HOME_URL: /
image: turnfold:local
container_name: xiteng-chat container_name: xiteng-chat
restart: unless-stopped restart: unless-stopped
environment: environment:
AUTH_MODE: forward-auth
AUTH_ISSUER: https://auth.xiteng.site
BASE_PATH: /chat
KEY_VAULT_URL: http://ai-gateway:8093 KEY_VAULT_URL: http://ai-gateway:8093
KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac
CHAT_DATABASE_PATH: /data/chat.db CHAT_DATABASE_PATH: /data/chat.db
PORTAL_URL: http://xiteng-site:8080 PORTAL_URL: http://xiteng-site:8080
ACCOUNT_URL: https://xiteng.site/account
PUBLIC_PROVIDER_CATALOG_FILE: /app/providers.json PUBLIC_PROVIDER_CATALOG_FILE: /app/providers.json
volumes: volumes:
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro - ../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.edge-ttl=604800"
- "xiteng.site.cache.chat.stale-while-revalidate=86400" - "xiteng.site.cache.chat.stale-while-revalidate=86400"
- "xiteng.site.component.chat.enabled=true" - "xiteng.site.component.chat.enabled=true"
- "xiteng.site.component.chat.name=Xiteng Chat" - "xiteng.site.component.chat.name=Turnfold"
- "xiteng.site.component.chat.description=本地优先的 AI 对话界面;无需登录即可使用浏览器 Provider,登录后启用个人同步与 Key Vault。" - "xiteng.site.component.chat.description=本地优先、支持分支式历史的 AI 对话仓库;无需登录即可使用浏览器 Provider,登录后启用个人同步与 Key Vault。"
- "xiteng.site.component.chat.section=services" - "xiteng.site.component.chat.section=services"
- "xiteng.site.component.chat.category=AI" - "xiteng.site.component.chat.category=AI"
- "xiteng.site.component.chat.url=https://xiteng.site/chat/" - "xiteng.site.component.chat.url=https://xiteng.site/chat/"
-7
View File
@@ -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
-333
View File
@@ -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});
}
-23
View File
@@ -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<string, string> = {"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
});
};
}
-24
View File
@@ -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}
};
}
-199
View File
@@ -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<T>(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<void>(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<RepositoryFetch>(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;
-18
View File
@@ -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("");
});
});
-13
View File
@@ -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()}`;
}
-21
View File
@@ -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("<think>no title</think>\n")).toBe("");
});
});
-37
View File
@@ -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(/<think>[\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;
}
-108
View File
@@ -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<Record<string, unknown> & {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;
};
-705
View File
@@ -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<StoredChatMessage, "id"> = {...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<string>();
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<string, unknown>;
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)!)};
}
-21
View File
@@ -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);
});
});
-8
View File
@@ -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;
}
-64
View File
@@ -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({});
});
});
-55
View File
@@ -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<string, unknown> : {};
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<string, {[key: string]: JsonValue}> = {};
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} : {})
};
}
-31
View File
@@ -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");
});
});
-32
View File
@@ -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<string, string> = {...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)! : "";
}
@@ -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);
});
});
-79
View File
@@ -1,79 +0,0 @@
import {splitStreamingMarkdown} from "./streaming-markdown";
export type IncrementalMarkdownBlock<T> = T & {
source: string;
type: string;
index: number;
stable: boolean;
};
export type IncrementalMarkdownResult<T> = {
blocks: Array<IncrementalMarkdownBlock<T>>;
parsed: number;
reused: number;
};
type IncrementalMarkdownState<T> = {
stableSource: string;
stableBlocks: Array<IncrementalMarkdownBlock<T>>;
nextIndex: number;
};
export class IncrementalMarkdownCache<T> {
private readonly states = new Map<string, IncrementalMarkdownState<T>>();
render(
messageId: string,
value: string,
renderBlock: (source: string, type: string, index: number, stable: boolean) => T,
onSplit?: (durationMs: number) => void,
complete = false
): IncrementalMarkdownResult<T> {
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);
}
}
-53
View File
@@ -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<Headers, "get">;
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)
});
}
-44
View File
@@ -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}]);
});
});
-66
View File
@@ -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<IDBDatabase>((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<T>(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest<T>) {
const database = await openDatabase();
return new Promise<T>((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<LocalCredential | undefined>("readonly", (store) => store.get(localCredentialId(providerId, name)));
}
export async function listLocalCredentials() {
return transaction<LocalCredential[]>("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)));
}
-39
View File
@@ -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("<code>$not_math$</code>");
expect(html).toContain("and <span");
expect(html).toContain(">$math$</span>");
expect(html.match(/class="math-fragment"/g)).toHaveLength(1);
});
test("escapes HTML embedded inside a math fragment", () => {
const {html} = parse("$x <img src=x onerror=alert(1)> y$");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
});
-41
View File
@@ -1,41 +0,0 @@
const mathPattern = /(?<!\\)\$\$[\s\S]*?(?<!\\)\$\$|\\\[[\s\S]*?\\\]|\\\([^\n]*?\\\)|(?<![\\$])\$(?!\$)(?:\\.|[^\\$\n])+?(?<!\\)\$(?!\$)/g;
const tokenPattern = /\uE000xiteng-math-(\d+)\uE001/g;
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[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("<code", offset);
const codeEnd = value.lastIndexOf("</code>", offset);
if (codeStart > codeEnd) return escapeHtml(fragment);
return `<span class="math-fragment" data-math-key="${mathFragmentKey(fragment, index)}">${escapeHtml(fragment)}</span>`;
});
}
-35
View File
@@ -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"]);
});
});
-62
View File
@@ -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<string, StoredChatMessage>, headMessageId: string | null) {
const reversed: StoredChatMessage[] = [];
const seen = new Set<string>();
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<string, StoredChatMessage>, 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<string, StoredChatMessage>, 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<string, StoredChatMessage>,
startId: string,
currentPathIds: Set<string>,
currentHeadMessageId: string | null
) {
if (currentPathIds.has(startId)) return currentHeadMessageId || startId;
let cursor = startId;
const seen = new Set<string>();
while (!seen.has(cursor)) {
seen.add(cursor);
const children = messageChildrenInGraph(messages, cursor);
if (!children.length) return cursor;
cursor = children.at(-1)!.id;
}
return startId;
}
-29
View File
@@ -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<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, canonicalValue(entry)]));
}
export function canonicalMessage(message: Omit<StoredChatMessage, "id">) {
return JSON.stringify(canonicalValue(message));
}
export async function messageObjectId(message: Omit<StoredChatMessage, "id">, 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<StoredChatMessage, "id">, namespace = ""): Promise<StoredChatMessage> {
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);
}
-14
View File
@@ -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");
});
});
-5
View File
@@ -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;
}
-763
View File
@@ -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<T = unknown> = {
id: string;
config: T;
summaries: ConversationSummary[];
updatedAt: string;
lastFetchAt?: string;
};
type CachedConversationRef = Omit<Conversation, "messages"> & {
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<IDBDatabase>((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<T>(storeName: string, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>) {
const database = await openDatabase();
return new Promise<T>((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<StoredChatMessage>, 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<ConversationSummary> & {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<void>((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<string, ConversationSummary>();
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<Record<string, unknown>>) {
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<string, unknown> | 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<T>(profileId: string, config: T) {
activateOfflineProfile(profileId);
const current = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
const profile: CachedProfile<T> = {
id: profileId,
config,
summaries: current?.summaries || [],
updatedAt: new Date().toISOString(),
lastFetchAt: current?.lastFetchAt
};
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
}
export async function loadCachedChatConfig<T>(requestedProfileId?: string) {
const profileId = requestedProfileId || activeProfileId();
if (!profileId) return null;
const profile = await transaction<CachedProfile<T> | 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<CachedProfile | undefined>("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<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
if (!profile) return;
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({...profile, lastFetchAt: timestamp}));
}
export async function cacheConversationSummaries(summaries: ConversationSummary[]) {
const profileId = activeProfileId();
if (!profileId) return;
const current = await transaction<CachedProfile | undefined>("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<IDBValidKey>("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<CachedProfile | undefined>("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<IDBValidKey>("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<IDBValidKey>("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<CachedMessage | undefined>("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<CachedConversationRef | undefined>("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<ConversationSummary> & {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<string>();
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<undefined>("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
await transaction<undefined>("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<undefined>("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<undefined>("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<IDBValidKey>("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<WorkingItem[]>((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<undefined>("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<string[]>((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<StoredChatMessage[]>((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<void>((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<void>((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<void>((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<RepositoryOutboxRecord | undefined>("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<IDBValidKey>("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<RepositoryOutboxRecord[]>((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<IDBValidKey>("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<RepositoryOutboxRecord | undefined>("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<RepositoryOutboxRecord | undefined>("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<string>();
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<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(key));
} else {
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(key));
if (pending) await transaction<IDBValidKey>("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<PendingConversationChange, "cacheKey" | "profileId" | "createdAt">) {
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<IDBValidKey>("pending", "readwrite", (store) => store.put(record));
}
export async function listPendingConversationChanges() {
const profileId = activeProfileId();
if (!profileId) return [];
const database = await openDatabase();
return new Promise<PendingConversationChange[]>((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<undefined>("pending", "readwrite", (store) => store.delete(cacheKey));
}
-5
View File
@@ -1,5 +0,0 @@
export type ChatProfile = {
username: string;
name: string;
email: string;
};
-188
View File
@@ -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<Response>;
type JsonRecord = Record<string, unknown>;
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
};
}
-66
View File
@@ -1,66 +0,0 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
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);
}
-45
View File
@@ -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<string, unknown>;
};
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<string, string>;
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<string, string>; baseUrl?: string};
proxy?: {username?: string; password?: string; token?: string};
};
export type ResolvedBackendProvider = {
provider: ProviderDefinition;
credential: {id: string; name: string; secret: ProviderSecret};
};
-20
View File
@@ -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);
});
});
-21
View File
@@ -1,21 +0,0 @@
import type {RepositoryRefUpdate, StoredChatMessage} from "./conversation-types";
export type RepositoryPushPayload = {
repositoryId: string;
objects: StoredChatMessage[];
refs: RepositoryRefUpdate[];
};
function chunks<T>(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];
}
-26
View File
@@ -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);
});
});
-26
View File
@@ -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};
}
-16
View File
@@ -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;
};
}
-81
View File
@@ -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);
});
});
-485
View File
@@ -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<string, unknown>;
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> = {}): 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<string>();
const canonicalAssistantText = new Set<string>();
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<string, string | null>, retained: Map<string, string>) {
const seen = new Set<string>();
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<string, string | null>();
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<string, string>();
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<string, string | null>();
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<string, string>();
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<string>();
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<SessionTransferFormat, "xiteng">) {
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} : {})}))
};
}
-48
View File
@@ -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);
});
});
-77
View File
@@ -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]*?(?<!\\)\$\$$/.test(value)
|| /^\\\[[\s\S]*?\\\]$/.test(value);
}
function isSelfClosing(token: BlockToken, following: BlockToken | undefined) {
if (token.type === "heading") return token.raw.endsWith("\n");
if (token.type === "hr") return true;
if (token.type === "code") return hasClosedFence(token.raw);
if (token.type === "paragraph") {
if (isClosedDisplayMath(token.raw)) return true;
return following?.type === "space" && /\n[\t ]*\n/.test(following.raw);
}
if (token.type === "def") return token.raw.endsWith("\n");
return false;
}
export function splitStreamingMarkdown(source: string, complete = false): StreamingMarkdownSplit {
if (!source) return {blocks: [], stableOffset: 0};
const tokens = marked.lexer(source) as unknown as BlockToken[];
const records: Array<BlockToken & {start: number; end: number}> = [];
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};
}
-30
View File
@@ -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"
}
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#171717"/><path d="M17 20h30v20H31l-9 8v-8h-5z" fill="#fff"/><circle cx="25" cy="30" r="2" fill="#171717"/><circle cx="32" cy="30" r="2" fill="#171717"/><circle cx="39" cy="30" r="2" fill="#171717"/></svg>

Before

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

-20
View File
@@ -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"}
]
}
-58
View File
@@ -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;
})));
}
});
-2608
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f4f0">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#111210">
<meta name="description" content="本地优先、可选登录同步的轻量 AI 对话界面">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Xiteng Chat">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>liooil / xiteng.site</title>
<link rel="manifest" href="/chat/manifest.webmanifest?v=__ASSET_VERSION__">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<link rel="stylesheet" href="/chat/styles.css?__ASSET_VERSION__">
<script type="module" src="/chat/assets/client.js?__ASSET_VERSION__"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
-576
View File
@@ -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<Record<string, unknown>>;
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<string, string> : {},
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<string, unknown>;
const connectionInput = input.connection as Record<string, unknown> | undefined;
const authInput = input.auth as Record<string, unknown> | undefined;
const discoveryInput = input.discovery as Record<string, unknown> | 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<string, unknown> | 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<Uint8Array>, 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<StoredChatMessage>;
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<string, unknown>;
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<Uint8Array>({
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<string, string> = {
".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}`);
-426
View File
@@ -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; }
}
-18
View File
@@ -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"]
}