feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.next
|
||||
package-lock.json
|
||||
npm-debug.log
|
||||
data
|
||||
run
|
||||
@@ -0,0 +1,25 @@
|
||||
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"]
|
||||
@@ -0,0 +1,20 @@
|
||||
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});
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"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",
|
||||
"marked": "15.0.12",
|
||||
"node-fetch": "3.3.2",
|
||||
"proxy-agent": "8.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.5",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
services:
|
||||
provider-proxy-host:
|
||||
image: node:24-alpine
|
||||
container_name: xiteng-provider-proxy-host
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
command: ["node", "/app/proxy-bridge.mjs"]
|
||||
environment:
|
||||
BRIDGE_MODE: host
|
||||
volumes:
|
||||
- ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro
|
||||
- ./run:/run/provider-proxy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const s=require('net').connect(7897,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(2000,()=>process.exit(1));s.on('error',()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
provider-proxy-bridge:
|
||||
image: node:24-alpine
|
||||
container_name: xiteng-provider-proxy-bridge
|
||||
restart: unless-stopped
|
||||
command: ["node", "/app/proxy-bridge.mjs"]
|
||||
environment:
|
||||
BRIDGE_MODE: network
|
||||
volumes:
|
||||
- ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro
|
||||
- ./run:/run/provider-proxy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const s=require('net').connect(17897,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(2000,()=>process.exit(1));s.on('error',()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- homelab_net
|
||||
|
||||
chat:
|
||||
build:
|
||||
context: .
|
||||
image: xiteng-chat:local
|
||||
container_name: xiteng-chat
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
KEY_VAULT_URL: http://ai-gateway:8093
|
||||
KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac
|
||||
CHAT_DATABASE_PATH: /data/chat.db
|
||||
PORTAL_URL: http://xiteng-site:8080
|
||||
volumes:
|
||||
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro
|
||||
- ./data:/data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- homelab_net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.xiteng-chat.loadbalancer.server.port=3000"
|
||||
- "traefik.http.middlewares.xiteng-chat-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.xiteng-chat-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
|
||||
- "traefik.http.middlewares.xiteng-chat-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.xiteng-chat-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-email,X-authentik-name,X-authentik-uid"
|
||||
- "traefik.http.routers.xiteng-chat.rule=Host(`chat.xiteng.site`)"
|
||||
- "traefik.http.routers.xiteng-chat.entrypoints=websecure"
|
||||
- "traefik.http.routers.xiteng-chat.tls=true"
|
||||
- "traefik.http.routers.xiteng-chat.tls.certresolver=cfresolver"
|
||||
- "traefik.http.routers.xiteng-chat.service=xiteng-chat"
|
||||
- "traefik.http.routers.xiteng-chat.middlewares=xiteng-chat-scheme,xiteng-chat-auth"
|
||||
- "traefik.http.routers.xiteng-chat-http.rule=Host(`chat.xiteng.site`)"
|
||||
- "traefik.http.routers.xiteng-chat-http.entrypoints=web"
|
||||
- "traefik.http.routers.xiteng-chat-http.service=xiteng-chat"
|
||||
- "traefik.http.routers.xiteng-chat-http.middlewares=xiteng-chat-scheme,xiteng-chat-auth"
|
||||
- "xiteng.site.component.chat.enabled=true"
|
||||
- "xiteng.site.component.chat.name=Xiteng Chat"
|
||||
- "xiteng.site.component.chat.description=基于 assistant-ui 与个人 Key Vault 的轻量 AI 对话界面。"
|
||||
- "xiteng.site.component.chat.section=services"
|
||||
- "xiteng.site.component.chat.category=AI"
|
||||
- "xiteng.site.component.chat.url=https://chat.xiteng.site"
|
||||
- "xiteng.site.component.chat.access=sso"
|
||||
- "xiteng.site.component.chat.access-label=需要登录"
|
||||
- "xiteng.site.component.chat.icon=AI"
|
||||
- "xiteng.site.component.chat.icon-url=https://xiteng.site/icons/services/chat.svg"
|
||||
- "xiteng.site.component.chat.accent=blue"
|
||||
- "xiteng.site.component.chat.order=190"
|
||||
- "xiteng.site.component.chat.navigation=new-tab"
|
||||
- "xiteng.site.component.chat.portal-link=embedded"
|
||||
- "xiteng.site.component.chat.monitor.enabled=true"
|
||||
- "xiteng.site.component.chat.monitor.url=http://xiteng-chat:3000/api/health"
|
||||
|
||||
networks:
|
||||
homelab_net:
|
||||
external: true
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,130 @@
|
||||
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";
|
||||
|
||||
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");
|
||||
|
||||
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}/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}${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"};
|
||||
let server;
|
||||
try {
|
||||
server = await startServer();
|
||||
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})
|
||||
});
|
||||
assert.equal(createdResult.response.status, 201);
|
||||
assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings);
|
||||
const conversationId = createdResult.payload.conversation.id;
|
||||
const messages = [
|
||||
{id: "user-1", role: "user", parts: [{type: "text", text: "persistent question"}]},
|
||||
{id: "assistant-1", role: "assistant", parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}], metadata: {custom: {response: {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20}}}}
|
||||
];
|
||||
const savedResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({providerId: "openai", model: "gpt-test", messages})
|
||||
});
|
||||
assert.equal(savedResult.response.status, 200);
|
||||
assert.equal(savedResult.payload.conversation.title, "persistent question");
|
||||
assert.equal(savedResult.payload.conversation.messageCount, 2);
|
||||
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})
|
||||
});
|
||||
assert.equal(settingsResult.response.status, 200);
|
||||
|
||||
await stopServer(server.child);
|
||||
server = await startServer();
|
||||
const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`);
|
||||
assert.equal(restored.response.status, 200);
|
||||
assert.deepEqual(restored.payload.conversation.messages, messages);
|
||||
assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings);
|
||||
|
||||
const ownerList = await api(server.origin, owner, "/api/conversations");
|
||||
assert.equal(ownerList.payload.conversations.length, 1);
|
||||
assert.equal(ownerList.payload.conversations[0].id, conversationId);
|
||||
|
||||
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);
|
||||
console.log("Chat history API tests passed");
|
||||
} finally {
|
||||
if (server) await stopServer(server.child);
|
||||
rmSync(temporaryDirectory, {recursive: true, force: true});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types";
|
||||
import type {GenerationSettings} from "./generation-settings";
|
||||
import {
|
||||
cacheConversation,
|
||||
cacheConversationSummaries,
|
||||
listPendingConversationChanges,
|
||||
loadCachedConversation,
|
||||
loadCachedConversationSummaries,
|
||||
queueConversationChange,
|
||||
removeCachedConversation,
|
||||
removePendingConversationChange
|
||||
} from "./offline-history";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isNetworkFailure(error: unknown) {
|
||||
return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
|
||||
}
|
||||
|
||||
export async function listConversationHistory() {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations");
|
||||
await cacheConversationSummaries(payload.conversations);
|
||||
return payload.conversations;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversationSummaries();
|
||||
if (cached.length === 0) throw error;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({providerId, model, generationSettings})
|
||||
});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
}
|
||||
|
||||
export async function getConversationHistory(id: string) {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationHistory(id: string, providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const body = JSON.stringify({providerId, model, generationSettings});
|
||||
try {
|
||||
return await conversationRequest<{updated: true}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PATCH", body});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (cached) await cacheConversation({...cached, providerId, model, generationSettings, updatedAt: new Date().toISOString()});
|
||||
await queueConversationChange({conversationId: id, method: "PATCH", body});
|
||||
return {updated: true as const};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
|
||||
const body = JSON.stringify({providerId, model, messages});
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PUT", body});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
const firstUserText = messages.find((message) => message.role === "user")?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text)).join(" ").replace(/\s+/g, " ").trim();
|
||||
const updated: Conversation = {
|
||||
...cached,
|
||||
title: firstUserText?.slice(0, 48) || cached.title,
|
||||
providerId,
|
||||
model,
|
||||
messages,
|
||||
messageCount: messages.length,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await cacheConversation(updated);
|
||||
await queueConversationChange({conversationId: id, method: "PUT", body});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConversationHistory(id: string) {
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
await queueConversationChange({conversationId: id, method: "DELETE"});
|
||||
}
|
||||
await removeCachedConversation(id);
|
||||
}
|
||||
|
||||
export async function flushPendingConversationChanges() {
|
||||
const pending = await listPendingConversationChanges();
|
||||
for (const change of pending) {
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(change.conversationId)}`, {
|
||||
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;
|
||||
}
|
||||
if (isNetworkFailure(error)) break;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeOfflineConversationHistory(summaries?: ConversationSummary[]) {
|
||||
const history = summaries || (await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations")).conversations;
|
||||
await cacheConversationSummaries(history);
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < history.length) {
|
||||
const summary = history[cursor++];
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(summary.id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker()));
|
||||
return history;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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()}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {GenerationSettings} from "./generation-settings";
|
||||
|
||||
export type ResponseMetadata = {
|
||||
providerId: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
outputTokens: number | null;
|
||||
tokensPerSecond: number | null;
|
||||
};
|
||||
|
||||
export type StoredChatMessage = {
|
||||
id: string;
|
||||
role: "system" | "user" | "assistant";
|
||||
parts: Array<Record<string, unknown> & {type: string}>;
|
||||
metadata?: {custom?: {response?: ResponseMetadata}};
|
||||
};
|
||||
|
||||
export type ConversationSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
providerId: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Conversation = ConversationSummary & {
|
||||
generationSettings: GenerationSettings;
|
||||
messages: StoredChatMessage[];
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import {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, ConversationSummary, ResponseMetadata, StoredChatMessage} from "./conversation-types";
|
||||
|
||||
const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db";
|
||||
let database: Database | undefined;
|
||||
|
||||
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 columns = opened.query("PRAGMA table_info(chat_conversation)").all() as Array<{name: string}>;
|
||||
if (!columns.some((column) => column.name === "settings_json")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'");
|
||||
}
|
||||
database = opened;
|
||||
return opened;
|
||||
}
|
||||
|
||||
type ConversationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
settings_json: string;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type MessageRow = {id: string; role: StoredChatMessage["role"]; parts_json: string};
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
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 conversationSummary(row: ConversationRow): ConversationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
providerId: row.provider_id,
|
||||
model: row.model,
|
||||
messageCount: Number(row.message_count),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessages(value: unknown): StoredChatMessage[] {
|
||||
if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries");
|
||||
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 = record.role;
|
||||
if (!["system", "user", "assistant"].includes(String(role))) throw new Error(`messages[${index}].role is invalid`);
|
||||
if (!Array.isArray(record.parts)) throw new Error(`messages[${index}].parts is required`);
|
||||
const partsJson = JSON.stringify(record.parts);
|
||||
if (Buffer.byteLength(partsJson) > 1024 * 1024) throw new Error(`messages[${index}] is too large`);
|
||||
const metadataJson = JSON.stringify(record.metadata || {});
|
||||
if (Buffer.byteLength(metadataJson) > 64 * 1024) throw new Error(`messages[${index}].metadata is too large`);
|
||||
return {
|
||||
id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(),
|
||||
role: role as StoredChatMessage["role"],
|
||||
parts: JSON.parse(partsJson) as StoredChatMessage["parts"],
|
||||
...(record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? {metadata: JSON.parse(metadataJson) as StoredChatMessage["metadata"]} : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function inferredTitle(messages: StoredChatMessage[]) {
|
||||
const userMessage = messages.find((message) => message.role === "user");
|
||||
const text = userMessage?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text))
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text ? text.slice(0, 60) : "新对话";
|
||||
}
|
||||
|
||||
function ownedConversation(identity: ChatIdentity, id: string) {
|
||||
return getDatabase().query(`
|
||||
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
|
||||
FROM chat_conversation c
|
||||
WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ?
|
||||
`).get(id, identity.issuer, identity.sub) as ConversationRow | undefined;
|
||||
}
|
||||
|
||||
export function listConversations(identity: ChatIdentity): ConversationSummary[] {
|
||||
return (getDatabase().query(`
|
||||
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
|
||||
FROM chat_conversation c
|
||||
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}): Conversation {
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const generationSettings = normalizeGenerationSettings(input.generationSettings);
|
||||
const timestamp = now();
|
||||
const id = randomUUID();
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
getDatabase().query(`
|
||||
DELETE FROM chat_conversation
|
||||
WHERE owner_issuer = ? AND owner_sub = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id)
|
||||
`).run(identity.issuer, identity.sub);
|
||||
getDatabase().query(`
|
||||
INSERT INTO chat_conversation (id, owner_issuer, owner_sub, title, provider_id, model, settings_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, '新对话', ?, ?, ?, ?, ?)
|
||||
`).run(id, identity.issuer, identity.sub, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp);
|
||||
getDatabase().run("COMMIT");
|
||||
} catch (error) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
return {...conversationSummary({id, title: "新对话", provider_id: providerId, model, settings_json: JSON.stringify(generationSettings), message_count: 0, created_at: timestamp, updated_at: timestamp}), generationSettings, messages: []};
|
||||
}
|
||||
|
||||
export function getConversation(identity: ChatIdentity, id: string): Conversation | null {
|
||||
const row = ownedConversation(identity, id);
|
||||
if (!row) return null;
|
||||
const messages = (getDatabase().query(`
|
||||
SELECT id, role, parts_json FROM chat_message WHERE conversation_id = ? ORDER BY ordinal
|
||||
`).all(id) as MessageRow[]).map((message) => {
|
||||
const parsed = JSON.parse(message.parts_json) as StoredChatMessage["parts"];
|
||||
const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object");
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
parts: parsed.filter((part) => part.type !== "data-response-metadata"),
|
||||
...(metadataPart ? {metadata: {custom: {response: metadataPart.data as ResponseMetadata}}} : {})
|
||||
};
|
||||
});
|
||||
return {...conversationSummary(row), generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), messages};
|
||||
}
|
||||
|
||||
export function updateConversationSettings(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; generationSettings?: unknown}) {
|
||||
if (!ownedConversation(identity, id)) return false;
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const generationSettings = normalizeGenerationSettings(input.generationSettings);
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET provider_id = ?, model = ?, settings_json = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function saveConversationMessages(identity: ChatIdentity, id: string, input: {
|
||||
providerId: unknown;
|
||||
model: unknown;
|
||||
messages: unknown;
|
||||
}) {
|
||||
const existing = ownedConversation(identity, id);
|
||||
if (!existing) return null;
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const messages = normalizeMessages(input.messages);
|
||||
const timestamp = now();
|
||||
const title = existing.title === "新对话" ? inferredTitle(messages) : existing.title;
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
getDatabase().query("DELETE FROM chat_message WHERE conversation_id = ?").run(id);
|
||||
const insert = getDatabase().query(`
|
||||
INSERT INTO chat_message (conversation_id, id, ordinal, role, parts_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
messages.forEach((message, ordinal) => {
|
||||
const parts = message.metadata?.custom?.response
|
||||
? [...message.parts, {type: "data-response-metadata", data: message.metadata.custom.response}]
|
||||
: message.parts;
|
||||
insert.run(id, message.id, ordinal, message.role, JSON.stringify(parts), timestamp);
|
||||
});
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub);
|
||||
getDatabase().run("COMMIT");
|
||||
} catch (error) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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} : {})
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type {Conversation, ConversationSummary} from "./conversation-types";
|
||||
|
||||
const databaseName = "xiteng-chat-offline";
|
||||
const databaseVersion = 1;
|
||||
const activeProfileKey = "xiteng-chat-offline-profile";
|
||||
|
||||
type CachedProfile<T = unknown> = {
|
||||
id: string;
|
||||
config: T;
|
||||
summaries: ConversationSummary[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CachedConversation = Conversation & {cacheKey: string; profileId: string};
|
||||
|
||||
export type PendingConversationChange = {
|
||||
cacheKey: string;
|
||||
profileId: string;
|
||||
conversationId: string;
|
||||
method: "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");
|
||||
}
|
||||
};
|
||||
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 conversationCacheKey(profileId: string, conversationId: string) {
|
||||
return `${profileId}:${conversationId}`;
|
||||
}
|
||||
|
||||
export function activateOfflineProfile(profileId: string) {
|
||||
window.localStorage.setItem(activeProfileKey, profileId);
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
|
||||
}
|
||||
|
||||
export async function loadCachedChatConfig<T>() {
|
||||
const profileId = 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} : null;
|
||||
}
|
||||
|
||||
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;
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({
|
||||
...current,
|
||||
summaries,
|
||||
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 || [];
|
||||
}
|
||||
|
||||
export async function cacheConversation(conversation: Conversation) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const record: CachedConversation = {
|
||||
...conversation,
|
||||
cacheKey: conversationCacheKey(profileId, conversation.id),
|
||||
profileId
|
||||
};
|
||||
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(record));
|
||||
const summaries = await loadCachedConversationSummaries();
|
||||
const summary: ConversationSummary = {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
providerId: conversation.providerId,
|
||||
model: conversation.model,
|
||||
messageCount: conversation.messageCount,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt
|
||||
};
|
||||
await cacheConversationSummaries([summary, ...summaries.filter((item) => item.id !== conversation.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
||||
}
|
||||
|
||||
export async function loadCachedConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return null;
|
||||
const record = await transaction<CachedConversation | undefined>("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id)));
|
||||
if (!record) return null;
|
||||
const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record;
|
||||
return conversation;
|
||||
}
|
||||
|
||||
export async function removeCachedConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id)));
|
||||
const summaries = await loadCachedConversationSummaries();
|
||||
await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id));
|
||||
}
|
||||
|
||||
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}`,
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ChatProfile = {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {ResponseMetadata} from "./conversation-types";
|
||||
|
||||
export function responseMetadata(providerId: string, model: string, startedAt: number, outputTokens: number | undefined | null): ResponseMetadata {
|
||||
const durationMs = Math.max(1, Math.round(performance.now() - startedAt));
|
||||
const normalizedTokens = typeof outputTokens === "number" && Number.isFinite(outputTokens) && outputTokens >= 0
|
||||
? Math.round(outputTokens)
|
||||
: null;
|
||||
const tokensPerSecond = normalizedTokens === null
|
||||
? null
|
||||
: Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10;
|
||||
return {providerId, model, durationMs, outputTokens: normalizedTokens, tokensPerSecond};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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",
|
||||
"marked": "15.0.12",
|
||||
"node-fetch": "3.3.2",
|
||||
"proxy-agent": "8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.5",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {mkdirSync, rmSync} from "node:fs";
|
||||
import net from "node:net";
|
||||
|
||||
const mode = process.env.BRIDGE_MODE || "network";
|
||||
const socketPath = process.env.BRIDGE_SOCKET || "/run/provider-proxy/upstream.sock";
|
||||
const listenPort = Number.parseInt(process.env.LISTEN_PORT || "17897", 10);
|
||||
const upstreamHost = process.env.UPSTREAM_HOST || "127.0.0.1";
|
||||
const upstreamPort = Number.parseInt(process.env.UPSTREAM_PORT || "7897", 10);
|
||||
|
||||
if (mode === "host") {
|
||||
mkdirSync("/run/provider-proxy", {recursive: true});
|
||||
rmSync(socketPath, {force: true});
|
||||
}
|
||||
|
||||
const server = net.createServer((client) => {
|
||||
client.pause();
|
||||
const upstream = mode === "host"
|
||||
? net.connect({host: upstreamHost, port: upstreamPort})
|
||||
: net.connect(socketPath);
|
||||
upstream.once("connect", () => {
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
client.resume();
|
||||
});
|
||||
const close = () => {
|
||||
client.destroy();
|
||||
upstream.destroy();
|
||||
};
|
||||
client.on("error", close);
|
||||
upstream.on("error", close);
|
||||
});
|
||||
|
||||
const listenTarget = mode === "host" ? socketPath : {host: "0.0.0.0", port: listenPort};
|
||||
server.listen(listenTarget, () => {
|
||||
const target = mode === "host" ? socketPath : `0.0.0.0:${listenPort}`;
|
||||
const upstream = mode === "host" ? `${upstreamHost}:${upstreamPort}` : socketPath;
|
||||
console.log(`provider proxy ${mode} bridge ${target} -> ${upstream}`);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
server.close(() => {
|
||||
if (mode === "host") rmSync(socketPath, {force: true});
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 309 B |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Xiteng Chat",
|
||||
"short_name": "Chat",
|
||||
"description": "使用个人 Key Vault 凭据的轻量 AI 对话界面",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"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"},
|
||||
{"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
|
||||
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const cacheName = "xiteng-chat-shell-v4";
|
||||
const shellAssets = [
|
||||
"/",
|
||||
"/manifest.webmanifest",
|
||||
"/favicon.svg",
|
||||
"/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 !== 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("/api/") || url.pathname.startsWith("/outpost.goauthentik.io/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(fetch(request).then((response) => {
|
||||
if (response.ok) caches.open(cacheName).then((cache) => cache.put("/", response.clone()));
|
||||
return response;
|
||||
}).catch(async () => (await caches.match("/")) || 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) caches.open(cacheName).then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,757 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import {marked} from "marked";
|
||||
import {applyBrowserProviderSettings} from "../lib/browser-provider-settings";
|
||||
import {createBrowserProviderFetch} from "../lib/browser-provider-fetch";
|
||||
import {
|
||||
createConversationHistory,
|
||||
deleteConversationHistory,
|
||||
flushPendingConversationChanges,
|
||||
getConversationHistory,
|
||||
listConversationHistory,
|
||||
saveConversationHistory,
|
||||
synchronizeOfflineConversationHistory,
|
||||
updateConversationHistory
|
||||
} from "../lib/conversation-client";
|
||||
import {conversationHash, conversationIdFromHash} from "../lib/conversation-hash";
|
||||
import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "../lib/conversation-types";
|
||||
import {defaultGenerationSettings, type GenerationSettings} from "../lib/generation-settings";
|
||||
import {
|
||||
activateOfflineProfile,
|
||||
cacheChatConfig,
|
||||
loadCachedChatConfig
|
||||
} from "../lib/offline-history";
|
||||
import {
|
||||
deleteLocalCredential,
|
||||
getLocalCredential,
|
||||
listLocalCredentials,
|
||||
saveLocalCredential,
|
||||
type LocalCredential
|
||||
} from "../lib/local-credentials";
|
||||
import type {ChatProfile} from "../lib/profile-types";
|
||||
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "../lib/provider-types";
|
||||
import {responseMetadata} from "../lib/response-metadata";
|
||||
|
||||
type ChatProvider = ProviderDefinition & {models: ProviderModel[]; modelDiscoveryError?: string};
|
||||
type ChatConfig = {providers: ChatProvider[]; profile: ChatProfile};
|
||||
type ServerChatConfig = ChatConfig & {identityKey: string};
|
||||
type CachedChatBootstrap = {config: ChatConfig; frontendProviders: ChatProvider[]};
|
||||
type StreamEvent = {type: string; text?: string; error?: string; metadata?: ResponseMetadata};
|
||||
type HashNavigationMode = "push" | "replace" | "none";
|
||||
|
||||
const rootElement = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!rootElement) throw new Error("Application root is missing");
|
||||
const root: HTMLDivElement = rootElement;
|
||||
|
||||
const state = {
|
||||
config: null as ChatConfig | null,
|
||||
frontendProviders: [] as ChatProvider[],
|
||||
localCredentials: [] as LocalCredential[],
|
||||
conversations: [] as ConversationSummary[],
|
||||
conversation: null as Conversation | null,
|
||||
providerId: "",
|
||||
model: "",
|
||||
generationSettings: {...defaultGenerationSettings},
|
||||
recentModelKeys: [] as string[],
|
||||
historyOpen: window.matchMedia("(min-width: 681px)").matches,
|
||||
offline: false,
|
||||
loading: true,
|
||||
error: "",
|
||||
modelQuery: "",
|
||||
streaming: false,
|
||||
streamController: null as AbortController | null,
|
||||
renderFrame: 0,
|
||||
settingsTimer: 0
|
||||
};
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
history: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16M4 12h10M4 19h16"/></svg>',
|
||||
plus: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg>',
|
||||
trash: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5m4-5v5"/></svg>',
|
||||
down: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m16 16 4 4"/></svg>',
|
||||
clock: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||||
more: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1l2-1-2-4-2 1a8 8 0 0 0-2-1l-.3-2h-5l-.3 2a8 8 0 0 0-2 1l-2-1-2 4 2 1a7 7 0 0 0 0 2l-2 1 2 4 2-1a8 8 0 0 0 2 1l.3 2h5l.3-2a8 8 0 0 0 2-1l2 1 2-4-2-1a7 7 0 0 0 .1-1Z"/></svg>',
|
||||
send: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 7-7 7 7M12 5v14"/></svg>',
|
||||
stop: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1"/></svg>',
|
||||
copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
|
||||
retry: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5"/><path d="M18 9a7 7 0 0 0-12-2l-2 3m2 5a7 7 0 0 0 12 2l2-3"/></svg>',
|
||||
scroll: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>',
|
||||
offline: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M8 8a9 9 0 0 1 12 2M5 12a9 9 0 0 1 2-2m3 6a3 3 0 0 1 4-1m-2 5h.01"/></svg>'
|
||||
};
|
||||
function uuid() {
|
||||
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]!);
|
||||
}
|
||||
|
||||
function messagePartText(message: StoredChatMessage, type: "text" | "reasoning") {
|
||||
return message.parts.filter((part) => part.type === type && typeof part.text === "string").map((part) => String(part.text)).join("");
|
||||
}
|
||||
|
||||
function markdown(value: string) {
|
||||
return DOMPurify.sanitize(marked.parse(value, {async: false, gfm: true, breaks: false}) as string);
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return state.config?.providers.find((item) => item.id === state.providerId) || null;
|
||||
}
|
||||
|
||||
function localCredential(providerId = state.providerId) {
|
||||
return state.localCredentials.find((item) => item.providerId === providerId && item.name === "default")
|
||||
|| state.localCredentials.find((item) => item.providerId === providerId)
|
||||
|| null;
|
||||
}
|
||||
|
||||
function updateConversationHash(id: string, mode: Exclude<HashNavigationMode, "none">) {
|
||||
const hash = conversationHash(id);
|
||||
if (window.location.hash === hash) return;
|
||||
const url = `${window.location.pathname}${window.location.search}${hash}`;
|
||||
window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", url);
|
||||
}
|
||||
|
||||
function settingsForProvider(item: ChatProvider) {
|
||||
const saved = window.localStorage.getItem(`xiteng-chat-model:${item.id}`) || "";
|
||||
const model = item.models.some((candidate) => candidate.id === saved)
|
||||
? saved
|
||||
: item.models.some((candidate) => candidate.id === item.defaultModel) ? item.defaultModel : item.models[0]?.id || "";
|
||||
return {model};
|
||||
}
|
||||
|
||||
function rememberModel(providerId: string, model: string) {
|
||||
if (!providerId || !model) return;
|
||||
const key = `${providerId}/${model}`;
|
||||
state.recentModelKeys = [key, ...state.recentModelKeys.filter((item) => item !== key)].slice(0, 20);
|
||||
window.localStorage.setItem("xiteng-chat-recent-models", JSON.stringify(state.recentModelKeys));
|
||||
}
|
||||
|
||||
function avatarPlaceholder(profile: ChatProfile) {
|
||||
const source = String(profile.name || profile.username || "U").trim() || "U";
|
||||
const parts = source.split(/\s+/).filter(Boolean);
|
||||
const initials = (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)![0]}` : [...source].slice(0, 2).join("")).toUpperCase();
|
||||
let hash = 0;
|
||||
for (const character of String(profile.username || profile.name || initials)) hash = ((hash << 5) - hash + character.codePointAt(0)!) | 0;
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="32" fill="hsl(${hue} 58% 48%)"/><text x="128" y="145" text-anchor="middle" font-family="system-ui,sans-serif" font-size="82" font-weight="800" fill="white">${escapeHtml(initials)}</text></svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
async function updateAvatar() {
|
||||
const image = root.querySelector<HTMLImageElement>(".header-avatar");
|
||||
const profile = state.config?.profile;
|
||||
if (!image || !profile?.email.trim()) return;
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(profile.email.trim().toLowerCase()));
|
||||
const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
for (const source of [`https://www.gravatar.com/avatar/${hash}?d=404&s=256`, `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256`]) {
|
||||
const loaded = await new Promise<boolean>((resolve) => {
|
||||
const candidate = new Image();
|
||||
const timer = window.setTimeout(() => resolve(false), 5000);
|
||||
candidate.onload = () => { window.clearTimeout(timer); resolve(true); };
|
||||
candidate.onerror = () => { window.clearTimeout(timer); resolve(false); };
|
||||
candidate.referrerPolicy = "no-referrer";
|
||||
candidate.src = source;
|
||||
});
|
||||
if (loaded && image.isConnected) {
|
||||
image.src = source;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelPicker() {
|
||||
const active = provider();
|
||||
if (!active || !state.config) return "";
|
||||
const query = state.modelQuery.trim().toLowerCase();
|
||||
const choices = state.config.providers.flatMap((item) => item.models.map((model) => ({provider: item, model, key: `${item.id}/${model.id}`})));
|
||||
const matches = choices.filter((choice) => !query || choice.key.toLowerCase().includes(query) || choice.model.name.toLowerCase().includes(query));
|
||||
const recent = state.recentModelKeys.map((key) => choices.find((choice) => choice.key === key)).filter((choice): choice is NonNullable<typeof choice> => Boolean(choice)).filter((choice) => matches.includes(choice));
|
||||
const option = (choice: typeof choices[number]) => `<button class="model-option${choice.provider.id === state.providerId && choice.model.id === state.model ? " active" : ""}" type="button" data-action="choose-model" data-provider="${escapeHtml(choice.provider.id)}" data-model="${escapeHtml(choice.model.id)}"><span><strong>${escapeHtml(choice.key)}</strong>${choice.model.name !== choice.model.id ? `<small>${escapeHtml(choice.model.name)}</small>` : ""}</span><small>${choice.provider.connection.type === "frontend" ? "Frontend" : "Backend"}</small></button>`;
|
||||
const groups = state.config.providers.map((item) => {
|
||||
const items = matches.filter((choice) => choice.provider.id === item.id);
|
||||
return items.length ? `<section class="model-menu-group"><h3>${escapeHtml(item.name)}</h3>${items.map(option).join("")}</section>` : "";
|
||||
}).join("");
|
||||
return `<details class="model-picker"><summary aria-label="Provider 和模型"><span>${escapeHtml(state.providerId)}/${escapeHtml(state.model)}</span>${icons.down}</summary><div class="model-menu"><label class="model-search">${icons.search}<input value="${escapeHtml(state.modelQuery)}" data-action="model-search" placeholder="搜索 provider/model"></label>${recent.length ? `<section class="model-menu-group recent-models"><h3>${icons.clock}最近使用</h3>${recent.map(option).join("")}</section>` : ""}${groups || '<p class="model-menu-empty">没有匹配的模型</p>'}</div></details>`;
|
||||
}
|
||||
|
||||
function renderLocalMenu() {
|
||||
if (!state.frontendProviders.length) return "";
|
||||
return `<details class="local-key-menu"><summary aria-label="Frontend Provider 设置">${icons.more}</summary><div><strong class="local-key-menu-title">Frontend Provider 设置</strong>${state.frontendProviders.map((item) => {
|
||||
const configured = state.localCredentials.some((credential) => credential.providerId === item.id);
|
||||
return `<section class="local-key-entry"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.auth.type === "none" ? item.connection.baseUrl : item.id)}</small>${item.modelDiscoveryError ? `<small class="local-key-error">${escapeHtml(item.modelDiscoveryError)}</small>` : ""}</span><div><button type="button" data-action="configure-local" data-provider="${escapeHtml(item.id)}">${item.auth.type === "none" ? "端点" : configured ? "更新" : "配置"}</button>${configured ? `<button class="dangerous" type="button" data-action="delete-local" data-provider="${escapeHtml(item.id)}">重置</button>` : ""}${item.auth.type === "none" ? `<button type="button" data-action="probe-local" data-provider="${escapeHtml(item.id)}">探测</button>` : ""}</div></section>`;
|
||||
}).join("")}</div></details>`;
|
||||
}
|
||||
|
||||
function renderGenerationMenu() {
|
||||
const settings = state.generationSettings;
|
||||
return `<details class="generation-menu"><summary aria-label="生成参数">${icons.settings}</summary><div><strong>生成参数</strong><label>思考强度<select data-setting="reasoning"><option value="auto"${settings.reasoning === "auto" ? " selected" : ""}>自动</option><option value="none"${settings.reasoning === "none" ? " selected" : ""}>关闭</option><option value="low"${settings.reasoning === "low" ? " selected" : ""}>低</option><option value="medium"${settings.reasoning === "medium" ? " selected" : ""}>中</option><option value="high"${settings.reasoning === "high" ? " selected" : ""}>高</option></select></label><label class="generation-check"><input type="checkbox" data-setting="showReasoningSummary"${settings.showReasoningSummary ? " checked" : ""}>显示思考摘要</label><label>Temperature<input type="number" min="0" max="2" step="0.1" placeholder="自动" data-setting="temperature" value="${settings.temperature ?? ""}"></label><label>最大输出 Tokens<input type="number" min="1" max="1000000" step="1" placeholder="自动" data-setting="maxOutputTokens" value="${settings.maxOutputTokens ?? ""}"></label><button type="button" data-action="reset-settings">恢复默认</button></div></details>`;
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
return `<button class="history-backdrop${state.historyOpen ? " open" : ""}" type="button" aria-label="关闭历史记录" data-action="close-history"></button><aside class="history-sidebar${state.historyOpen ? " open" : ""}" aria-label="聊天历史"><div class="history-heading"><strong>聊天历史</strong><div><button type="button" data-action="new-conversation" aria-label="新对话"${state.offline ? " disabled title=\"联网后可新建对话\"" : ""}>${icons.plus}</button><button class="history-close" type="button" data-action="close-history" aria-label="关闭历史记录">${icons.close}</button></div></div><div class="history-list">${state.conversations.map((item) => `<article class="history-item${item.id === state.conversation?.id ? " active" : ""}"><button class="history-select" type="button" data-action="select-conversation" data-id="${escapeHtml(item.id)}"><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.providerId)} · ${escapeHtml(item.model)}</small></button><button class="history-delete" type="button" data-action="delete-conversation" data-id="${escapeHtml(item.id)}" aria-label="删除 ${escapeHtml(item.title)}">${icons.trash}</button></article>`).join("")}</div></aside>`;
|
||||
}
|
||||
|
||||
function renderMessage(message: StoredChatMessage, index: number) {
|
||||
if (message.role === "user") {
|
||||
return `<article class="message user-message"><div class="message-content user-content"><p>${escapeHtml(messagePartText(message, "text"))}</p></div></article>`;
|
||||
}
|
||||
if (message.role !== "assistant") return "";
|
||||
const reasoning = messagePartText(message, "reasoning");
|
||||
const text = messagePartText(message, "text");
|
||||
const error = message.parts.find((part) => part.type === "error" && typeof part.text === "string")?.text;
|
||||
const response = message.metadata?.custom?.response;
|
||||
const modelLabel = response?.model ? `${response.providerId || state.providerId}/${response.model}` : `${state.providerId}/${state.model}`;
|
||||
const speed = typeof response?.tokensPerSecond === "number" ? `${response.tokensPerSecond.toFixed(1)} tok/s` : "速度 —";
|
||||
const detail = response?.durationMs ? `${(response.durationMs / 1000).toFixed(1)} 秒${typeof response.outputTokens === "number" ? ` · ${response.outputTokens} tokens` : ""}` : "历史回复未记录速度";
|
||||
return `<article class="message assistant-message" data-message-index="${index}"><div class="message-content assistant-content">${reasoning ? `<details class="message-reasoning"><summary>思考过程</summary><div>${escapeHtml(reasoning)}</div></details>` : ""}${text ? `<div class="aui-md">${markdown(text)}</div>` : state.streaming && index === state.conversation!.messages.length - 1 ? '<span class="response-loader"></span>' : ""}${error ? `<div class="message-error">${escapeHtml(error)}</div>` : ""}</div><div class="message-footer"><div class="response-meta" title="${escapeHtml(detail)}"><span>${escapeHtml(modelLabel)}</span><span>${escapeHtml(speed)}</span></div><div class="message-actions"><button class="icon-button" type="button" data-action="copy-message" data-index="${index}" aria-label="复制回答">${icons.copy}</button><button class="icon-button" type="button" data-action="regenerate-message" data-index="${index}" aria-label="重新生成"${state.streaming || state.offline ? " disabled" : ""}>${icons.retry}</button></div></div></article>`;
|
||||
}
|
||||
|
||||
function renderMessagesMarkup() {
|
||||
const messages = state.conversation?.messages || [];
|
||||
if (!messages.length) {
|
||||
const description = provider()?.connection.type === "frontend"
|
||||
? "Frontend Provider 由当前浏览器直连;对话记录按 Authentik 身份保存在服务端。"
|
||||
: "Backend Provider 由 Chat 服务端直连;对话记录按 Authentik 身份保存在服务端。";
|
||||
return `<div class="welcome"><div class="welcome-mark">XT</div><h1>今天想聊什么?</h1><p>${escapeHtml(description)}</p></div>`;
|
||||
}
|
||||
return messages.map(renderMessage).join("");
|
||||
}
|
||||
|
||||
function renderThread() {
|
||||
return `<section class="thread-root"><div class="thread-viewport" id="thread-viewport"><div id="message-list">${renderMessagesMarkup()}</div><div class="thread-footer"><button class="scroll-button" type="button" data-action="scroll-bottom" aria-label="滚动到底部">${icons.scroll}</button><form class="composer" id="composer"><textarea class="composer-input" name="message" placeholder="输入消息,Enter 发送,Shift + Enter 换行" rows="1" aria-label="聊天消息"${state.offline ? " disabled" : ""}></textarea><button class="send-button" type="${state.streaming ? "button" : "submit"}" data-action="${state.streaming ? "stop" : "send"}" aria-label="${state.streaming ? "停止生成" : "发送消息"}"${state.offline ? " disabled" : ""}>${state.streaming ? icons.stop : icons.send}</button></form><p class="composer-note${state.offline ? " offline" : ""}">${state.offline ? "离线模式:可查看本机历史;联网后自动同步。" : "模型可能会出错,请核对重要信息。"}</p></div></div></section>`;
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
if (state.error) {
|
||||
root.innerHTML = `<main class="state-page"><div class="state-card">${renderLocalMenu()}<span class="state-mark">!</span><h1>聊天服务暂时不可用</h1><p>${escapeHtml(state.error)}</p></div></main>`;
|
||||
return;
|
||||
}
|
||||
if (state.loading || !state.config || !provider() || !state.conversation) {
|
||||
root.innerHTML = '<main class="state-page"><div class="state-card"><span class="loader"></span><p>正在读取聊天历史与 Provider Registry…</p></div></main>';
|
||||
return;
|
||||
}
|
||||
const profile = state.config.profile;
|
||||
root.innerHTML = `<main class="app-shell with-history${state.historyOpen ? " history-open" : ""}"><header class="app-header"><div class="header-leading"><button class="history-toggle" type="button" data-action="toggle-history" aria-label="聊天历史">${icons.history}</button></div><div class="brand"><span class="brand-mark">XT</span><span>Xiteng Chat</span>${state.offline ? `<span class="offline-badge">${icons.offline}离线历史</span>` : ""}</div><div class="chat-controls">${renderModelPicker()}${renderLocalMenu()}${renderGenerationMenu()}<a class="header-button account-button" href="https://xiteng.site/account" aria-label="打开我的账户"><img class="header-avatar" src="${avatarPlaceholder(profile)}" alt="${escapeHtml(profile.name || profile.username)} 的头像" referrerpolicy="no-referrer"><span>${escapeHtml(profile.name || profile.username)}</span></a></div></header>${renderHistory()}${renderThread()}</main>`;
|
||||
void updateAvatar();
|
||||
}
|
||||
|
||||
function renderMessages(scroll = false) {
|
||||
const list = root.querySelector<HTMLElement>("#message-list");
|
||||
if (!list) {
|
||||
renderApp();
|
||||
return;
|
||||
}
|
||||
list.innerHTML = renderMessagesMarkup();
|
||||
const footer = root.querySelector<HTMLElement>(".thread-footer");
|
||||
if (footer) footer.outerHTML = new DOMParser().parseFromString(renderThread(), "text/html").querySelector(".thread-footer")!.outerHTML;
|
||||
if (scroll) scrollBottom();
|
||||
}
|
||||
|
||||
function scheduleMessagesRender(scroll = true) {
|
||||
if (state.renderFrame) return;
|
||||
state.renderFrame = window.requestAnimationFrame(() => {
|
||||
state.renderFrame = 0;
|
||||
renderMessages(scroll);
|
||||
});
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
const viewport = root.querySelector<HTMLElement>("#thread-viewport");
|
||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||
}
|
||||
|
||||
function closeHistoryOnMobile() {
|
||||
if (window.matchMedia("(max-width: 680px)").matches) state.historyOpen = false;
|
||||
}
|
||||
|
||||
function providerHeaders(item: ChatProvider, secret: ProviderSecret, initial?: HeadersInit) {
|
||||
const headers = new Headers(initial);
|
||||
for (const [name, value] of Object.entries(item.headers || {})) headers.set(name, value);
|
||||
for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value);
|
||||
const apiKey = secret.provider?.apiKey || "";
|
||||
if (item.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
if (item.auth.type === "header" && item.auth.header && apiKey) headers.set(item.auth.header, apiKey);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function normalizeModels(payload: unknown): ProviderModel[] {
|
||||
const root = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
|
||||
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
|
||||
return source.slice(0, 300).flatMap((value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const model = value as Record<string, unknown>;
|
||||
const rawId = model.id || model.name || model.model;
|
||||
if (typeof rawId !== "string" || !rawId.trim()) return [];
|
||||
const id = rawId.replace(/^models\//, "");
|
||||
return [{id, name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id}];
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverFrontendProvider(item: ChatProvider, secret: ProviderSecret) {
|
||||
const effective = applyBrowserProviderSettings(item, secret) as ChatProvider;
|
||||
const providerFetch = createBrowserProviderFetch(effective, secret);
|
||||
const response = await providerFetch(effective.discovery.url, {
|
||||
headers: providerHeaders(effective, secret, {"Accept": "application/json"}),
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
let models = response.ok ? normalizeModels(payload) : [];
|
||||
if (effective.id === "llama.cpp" && !models.length) {
|
||||
const propsUrl = `${effective.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
|
||||
const props = await providerFetch(propsUrl, {headers: providerHeaders(effective, secret), signal: AbortSignal.timeout(5000)});
|
||||
const value = await props.json() as {model_alias?: string; model_path?: string};
|
||||
const id = value.model_alias?.trim() || value.model_path?.split(/[\\/]/).filter(Boolean).at(-1) || effective.defaultModel;
|
||||
if (props.ok) models = [{id, name: id}];
|
||||
}
|
||||
if (!response.ok && !models.length) throw new Error(`Provider HTTP ${response.status}`);
|
||||
return {...effective, models, modelDiscoveryError: undefined};
|
||||
}
|
||||
|
||||
async function parseLines(response: Response, onLine: (line: string) => void | Promise<void>) {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as {error?: string} | null;
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.body) throw new Error("Streaming response body is unavailable");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), {stream: !done});
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) if (line.trim()) await onLine(line.trim());
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) await onLine(buffer.trim());
|
||||
}
|
||||
|
||||
async function streamBackend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal) {
|
||||
const item = provider()!;
|
||||
const credential = item.credentials.find((value) => value.name === "default") || item.credentials[0];
|
||||
if (!credential) throw new Error(`请先在 Key Vault 中配置 ${item.name}`);
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({conversationId: state.conversation!.id, providerId: item.id, credentialName: credential.name, model: state.model, generationSettings: state.generationSettings, messages}),
|
||||
signal
|
||||
});
|
||||
await parseLines(response, (line) => onEvent(JSON.parse(line) as StreamEvent));
|
||||
}
|
||||
|
||||
async function streamFrontend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal) {
|
||||
const item = provider()!;
|
||||
if (item.api !== "openai-completions") throw new Error(`Frontend Provider 暂不支持 ${item.api}`);
|
||||
const credential = localCredential(item.id);
|
||||
const secret = credential?.secret || {};
|
||||
const effective = applyBrowserProviderSettings(item, secret) as ChatProvider;
|
||||
const providerFetch = createBrowserProviderFetch(effective, secret);
|
||||
const startedAt = performance.now();
|
||||
let outputTokens: number | null = null;
|
||||
const response = await providerFetch(`${effective.connection.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: providerHeaders(effective, secret, {"Content-Type": "application/json", "Accept": "text/event-stream"}),
|
||||
body: JSON.stringify({
|
||||
model: state.model,
|
||||
messages: messages.filter((message) => message.role !== "system" || messagePartText(message, "text")).map((message) => ({role: message.role, content: messagePartText(message, "text")})),
|
||||
stream: true,
|
||||
stream_options: {include_usage: true},
|
||||
...(state.generationSettings.temperature !== null ? {temperature: state.generationSettings.temperature} : {}),
|
||||
...(state.generationSettings.maxOutputTokens !== null ? {max_tokens: state.generationSettings.maxOutputTokens} : {}),
|
||||
...(state.generationSettings.reasoning !== "auto" && state.generationSettings.reasoning !== "none" ? {reasoning_effort: state.generationSettings.reasoning} : {})
|
||||
}),
|
||||
signal
|
||||
});
|
||||
await parseLines(response, (line) => {
|
||||
if (!line.startsWith("data:")) return;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === "[DONE]") return;
|
||||
const payload = JSON.parse(data) as {choices?: Array<{delta?: {content?: string; reasoning?: string; reasoning_content?: string}}>; usage?: {completion_tokens?: number; output_tokens?: number}};
|
||||
const delta = payload.choices?.[0]?.delta;
|
||||
if (delta?.reasoning_content || delta?.reasoning) onEvent({type: "reasoning-delta", text: delta.reasoning_content || delta.reasoning});
|
||||
if (delta?.content) onEvent({type: "text-delta", text: delta.content});
|
||||
const reported = payload.usage?.completion_tokens ?? payload.usage?.output_tokens;
|
||||
if (typeof reported === "number") outputTokens = reported;
|
||||
});
|
||||
onEvent({type: "finish", metadata: responseMetadata(item.id, state.model, startedAt, outputTokens)});
|
||||
}
|
||||
|
||||
async function refreshConversations() {
|
||||
state.conversations = await listConversationHistory();
|
||||
}
|
||||
|
||||
async function generateAssistant(baseMessages: StoredChatMessage[]) {
|
||||
const assistant: StoredChatMessage = {id: uuid(), role: "assistant", parts: []};
|
||||
state.conversation!.messages = [...baseMessages, assistant];
|
||||
state.streaming = true;
|
||||
state.streamController = new AbortController();
|
||||
renderApp();
|
||||
scrollBottom();
|
||||
let text = "";
|
||||
let reasoning = "";
|
||||
let finished = false;
|
||||
const onEvent = (event: StreamEvent) => {
|
||||
if (event.type === "text-delta" && event.text) text += event.text;
|
||||
if (event.type === "reasoning-delta" && event.text) reasoning += event.text;
|
||||
assistant.parts = [
|
||||
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
|
||||
...(text ? [{type: "text", text}] : [])
|
||||
];
|
||||
if (event.type === "finish" && event.metadata) {
|
||||
assistant.metadata = {custom: {response: event.metadata}};
|
||||
finished = true;
|
||||
}
|
||||
if (event.type === "error") throw new Error(event.error || "生成失败");
|
||||
scheduleMessagesRender();
|
||||
};
|
||||
try {
|
||||
if (provider()!.connection.type === "backend") await streamBackend(baseMessages, onEvent, state.streamController.signal);
|
||||
else await streamFrontend(baseMessages, onEvent, state.streamController.signal);
|
||||
if (!finished) throw new Error("Provider 未返回完成事件");
|
||||
state.conversation = await saveConversationHistory(state.conversation!.id, state.providerId, state.model, [...baseMessages, assistant]);
|
||||
await refreshConversations();
|
||||
} catch (error) {
|
||||
if (state.streamController.signal.aborted) {
|
||||
assistant.parts = [
|
||||
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
|
||||
...(text ? [{type: "text", text}] : []),
|
||||
{type: "error", text: "已停止生成"}
|
||||
];
|
||||
} else {
|
||||
assistant.parts = [
|
||||
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
|
||||
...(text ? [{type: "text", text}] : []),
|
||||
{type: "error", text: error instanceof Error ? error.message : "生成失败"}
|
||||
];
|
||||
}
|
||||
} finally {
|
||||
state.streaming = false;
|
||||
state.streamController = null;
|
||||
renderApp();
|
||||
scrollBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (state.streaming || state.offline || !state.conversation || !text.trim()) return;
|
||||
const user: StoredChatMessage = {id: uuid(), role: "user", parts: [{type: "text", text: text.trim()}]};
|
||||
const messages = [...state.conversation.messages, user];
|
||||
state.conversation.messages = messages;
|
||||
renderApp();
|
||||
scrollBottom();
|
||||
state.conversation = await saveConversationHistory(state.conversation.id, state.providerId, state.model, messages);
|
||||
await refreshConversations();
|
||||
await generateAssistant(messages);
|
||||
}
|
||||
|
||||
async function regenerate(index: number) {
|
||||
if (state.streaming || state.offline || !state.conversation) return;
|
||||
const message = state.conversation.messages[index];
|
||||
if (!message || message.role !== "assistant") return;
|
||||
const base = state.conversation.messages.slice(0, index);
|
||||
if (!base.some((item) => item.role === "user")) return;
|
||||
state.conversation.messages = base;
|
||||
await saveConversationHistory(state.conversation.id, state.providerId, state.model, base);
|
||||
await generateAssistant(base);
|
||||
}
|
||||
|
||||
async function selectConversation(id: string, navigation: HashNavigationMode = "push") {
|
||||
if (!state.config || state.conversation?.id === id) return;
|
||||
const selected = await getConversationHistory(id);
|
||||
const selectedProvider = state.config.providers.find((item) => item.id === selected.providerId) || state.config.providers[0];
|
||||
if (!selectedProvider) return;
|
||||
state.conversation = selected;
|
||||
state.providerId = selectedProvider.id;
|
||||
state.model = selected.model || settingsForProvider(selectedProvider).model;
|
||||
state.generationSettings = selected.generationSettings;
|
||||
rememberModel(state.providerId, state.model);
|
||||
closeHistoryOnMobile();
|
||||
if (navigation !== "none") updateConversationHash(selected.id, navigation);
|
||||
renderApp();
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
const item = provider();
|
||||
if (state.offline || !item || !state.model) return;
|
||||
const created = await createConversationHistory(item.id, state.model, state.generationSettings);
|
||||
state.conversation = created;
|
||||
state.generationSettings = created.generationSettings;
|
||||
await refreshConversations();
|
||||
closeHistoryOnMobile();
|
||||
updateConversationHash(created.id, "push");
|
||||
renderApp();
|
||||
}
|
||||
|
||||
async function removeConversation(id: string) {
|
||||
const target = state.conversations.find((item) => item.id === id);
|
||||
if (!target || !window.confirm(`删除对话“${target.title}”?`)) return;
|
||||
await deleteConversationHistory(id);
|
||||
state.conversations = await listConversationHistory();
|
||||
if (state.conversation?.id === id) {
|
||||
if (state.conversations[0]) await selectConversation(state.conversations[0].id, "replace");
|
||||
else if (!state.offline) await newConversation();
|
||||
else state.error = "离线缓存中已没有聊天记录,请联网后新建对话。";
|
||||
}
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function chooseModel(providerId: string, model: string) {
|
||||
const item = state.config?.providers.find((candidate) => candidate.id === providerId);
|
||||
if (!item || !model || !state.conversation) return;
|
||||
state.providerId = item.id;
|
||||
state.model = model;
|
||||
state.conversation = {...state.conversation, providerId: item.id, model};
|
||||
state.modelQuery = "";
|
||||
window.localStorage.setItem("xiteng-chat-provider", item.id);
|
||||
window.localStorage.setItem(`xiteng-chat-model:${item.id}`, model);
|
||||
rememberModel(item.id, model);
|
||||
scheduleSettingsSave();
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function scheduleSettingsSave() {
|
||||
window.clearTimeout(state.settingsTimer);
|
||||
state.settingsTimer = window.setTimeout(() => {
|
||||
if (!state.conversation) return;
|
||||
void updateConversationHistory(state.conversation.id, state.providerId, state.model, state.generationSettings).catch((error) => console.error("Unable to save conversation settings", error));
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function configureLocal(providerId: string) {
|
||||
const item = state.frontendProviders.find((candidate) => candidate.id === providerId);
|
||||
if (!item) return;
|
||||
const current = await getLocalCredential(item.id);
|
||||
if (item.auth.type === "none") {
|
||||
const baseUrl = window.prompt(`${item.name} Base URL:`, current?.secret.provider?.baseUrl || item.connection.baseUrl);
|
||||
if (baseUrl === null) return;
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error();
|
||||
} catch {
|
||||
window.alert("Base URL 必须是有效的 http 或 https URL");
|
||||
return;
|
||||
}
|
||||
await saveLocalCredential(item.id, "default", {...current?.secret, provider: {...current?.secret.provider, baseUrl: baseUrl.replace(/\/+$/, "")}});
|
||||
} else {
|
||||
const apiKey = window.prompt(`${item.name} API Key:`, current?.secret.provider?.apiKey || "");
|
||||
if (apiKey === null) return;
|
||||
const proxyToken = item.connection.proxy?.type === "relay" ? window.prompt("Relay Token(没有则留空):", current?.secret.proxy?.token || "") || "" : "";
|
||||
await saveLocalCredential(item.id, "default", {provider: {apiKey}, ...(proxyToken ? {proxy: {token: proxyToken}} : {})});
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function probeLocal(providerId: string) {
|
||||
const item = state.frontendProviders.find((candidate) => candidate.id === providerId);
|
||||
if (!item) return;
|
||||
try {
|
||||
const detected = await discoverFrontendProvider(item, (await getLocalCredential(item.id))?.secret || {});
|
||||
state.frontendProviders = state.frontendProviders.map((candidate) => candidate.id === detected.id ? detected : candidate);
|
||||
if (state.config) state.config.providers = [...state.config.providers.filter((candidate) => candidate.id !== detected.id), detected];
|
||||
window.alert(`探测成功:发现 ${detected.models.length} 个模型`);
|
||||
renderApp();
|
||||
} catch (error) {
|
||||
window.alert(`探测失败:${error instanceof Error ? error.message : "未知错误"}\n\n请确认浏览器已允许 chat.xiteng.site 的“本地网络访问”权限。`);
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
let onlineBootstrap = true;
|
||||
let identityKey = "";
|
||||
let rawProviders: ChatProvider[] = [];
|
||||
let profile: ChatProfile = {username: "", name: "", email: ""};
|
||||
let cached: CachedChatBootstrap | null = null;
|
||||
try {
|
||||
const response = await fetch("/api/config", {cache: "no-store"});
|
||||
const payload = await response.json() as ServerChatConfig & {error?: string};
|
||||
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
identityKey = payload.identityKey;
|
||||
rawProviders = payload.providers;
|
||||
profile = payload.profile;
|
||||
activateOfflineProfile(identityKey);
|
||||
await flushPendingConversationChanges();
|
||||
} catch (error) {
|
||||
const stored = await loadCachedChatConfig<CachedChatBootstrap>();
|
||||
if (!stored) throw error;
|
||||
onlineBootstrap = false;
|
||||
identityKey = stored.profileId;
|
||||
cached = stored.config;
|
||||
activateOfflineProfile(identityKey);
|
||||
}
|
||||
state.localCredentials = await listLocalCredentials();
|
||||
let configured: ChatConfig;
|
||||
if (onlineBootstrap) {
|
||||
const providers = await Promise.all(rawProviders.map(async (item) => {
|
||||
if (item.connection.type === "backend") return item;
|
||||
const credential = state.localCredentials.find((value) => value.providerId === item.id && value.name === "default") || state.localCredentials.find((value) => value.providerId === item.id);
|
||||
if (item.auth.type !== "none" && !credential) return {...item, models: []};
|
||||
try {
|
||||
return await discoverFrontendProvider(item, credential?.secret || {});
|
||||
} catch (error) {
|
||||
return {...item, models: [], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"};
|
||||
}
|
||||
}));
|
||||
state.frontendProviders = providers.filter((item) => item.connection.type === "frontend");
|
||||
configured = {
|
||||
profile,
|
||||
providers: providers.filter((item) => item.models.length > 0 && (item.connection.type === "backend" ? item.credentials.length > 0 : item.auth.type === "none" || state.localCredentials.some((credential) => credential.providerId === item.id)))
|
||||
};
|
||||
await cacheChatConfig(identityKey, {config: configured, frontendProviders: state.frontendProviders});
|
||||
} else {
|
||||
configured = cached!.config;
|
||||
state.frontendProviders = cached!.frontendProviders;
|
||||
}
|
||||
state.config = configured;
|
||||
state.offline = !onlineBootstrap || !navigator.onLine;
|
||||
state.conversations = await listConversationHistory();
|
||||
try {
|
||||
const recent = JSON.parse(window.localStorage.getItem("xiteng-chat-recent-models") || "[]");
|
||||
if (Array.isArray(recent)) state.recentModelKeys = recent.filter((item) => typeof item === "string").slice(0, 20);
|
||||
} catch {
|
||||
window.localStorage.removeItem("xiteng-chat-recent-models");
|
||||
}
|
||||
const hashId = conversationIdFromHash(window.location.hash);
|
||||
const selectedSummary = state.conversations.find((item) => item.id === hashId) || state.conversations[0];
|
||||
if (selectedSummary) {
|
||||
const selected = await getConversationHistory(selectedSummary.id);
|
||||
const selectedProvider = configured.providers.find((item) => item.id === selected.providerId) || configured.providers[0];
|
||||
if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据");
|
||||
state.conversation = selected;
|
||||
state.providerId = selectedProvider.id;
|
||||
state.model = selected.model || settingsForProvider(selectedProvider).model;
|
||||
state.generationSettings = selected.generationSettings;
|
||||
rememberModel(state.providerId, state.model);
|
||||
updateConversationHash(selected.id, "replace");
|
||||
if (onlineBootstrap) void synchronizeOfflineConversationHistory(state.conversations).catch((error) => console.error("Unable to refresh offline history", error));
|
||||
} else {
|
||||
if (!onlineBootstrap) throw new Error("离线缓存中还没有聊天记录,请联网后再试。");
|
||||
const savedProviderId = window.localStorage.getItem("xiteng-chat-provider") || "";
|
||||
const selectedProvider = configured.providers.find((item) => item.id === savedProviderId) || configured.providers[0];
|
||||
if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据");
|
||||
const selection = settingsForProvider(selectedProvider);
|
||||
state.conversation = await createConversationHistory(selectedProvider.id, selection.model, defaultGenerationSettings);
|
||||
state.conversations = [state.conversation];
|
||||
state.providerId = selectedProvider.id;
|
||||
state.model = selection.model;
|
||||
state.generationSettings = state.conversation.generationSettings;
|
||||
rememberModel(state.providerId, state.model);
|
||||
updateConversationHash(state.conversation.id, "replace");
|
||||
}
|
||||
state.loading = false;
|
||||
renderApp();
|
||||
}
|
||||
|
||||
root.addEventListener("submit", (event) => {
|
||||
if (!(event.target instanceof HTMLFormElement) || event.target.id !== "composer") return;
|
||||
event.preventDefault();
|
||||
const input = event.target.elements.namedItem("message");
|
||||
if (input instanceof HTMLTextAreaElement) void sendMessage(input.value).catch(showError);
|
||||
});
|
||||
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (!(event.target instanceof HTMLTextAreaElement) || event.target.name !== "message") return;
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void sendMessage(event.target.value).catch(showError);
|
||||
}
|
||||
});
|
||||
|
||||
root.addEventListener("input", (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLTextAreaElement && target.name === "message") {
|
||||
target.style.height = "auto";
|
||||
target.style.height = `${Math.min(target.scrollHeight, 180)}px`;
|
||||
}
|
||||
if (target instanceof HTMLInputElement && target.dataset.action === "model-search") {
|
||||
state.modelQuery = target.value;
|
||||
const details = target.closest("details");
|
||||
renderApp();
|
||||
const next = root.querySelector<HTMLInputElement>('[data-action="model-search"]');
|
||||
const nextDetails = next?.closest("details");
|
||||
if (nextDetails) nextDetails.open = true;
|
||||
next?.focus();
|
||||
next?.setSelectionRange(next.value.length, next.value.length);
|
||||
if (details?.open && nextDetails) nextDetails.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
root.addEventListener("change", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement) || !target.dataset.setting) return;
|
||||
const key = target.dataset.setting as keyof GenerationSettings;
|
||||
if (key === "showReasoningSummary" && target instanceof HTMLInputElement) state.generationSettings.showReasoningSummary = target.checked;
|
||||
if (key === "reasoning") state.generationSettings.reasoning = target.value as GenerationSettings["reasoning"];
|
||||
if (key === "temperature") state.generationSettings.temperature = target.value === "" ? null : Math.min(2, Math.max(0, Number(target.value)));
|
||||
if (key === "maxOutputTokens") state.generationSettings.maxOutputTokens = target.value === "" ? null : Math.min(1_000_000, Math.max(1, Math.floor(Number(target.value))));
|
||||
if (state.conversation) state.conversation.generationSettings = {...state.generationSettings};
|
||||
scheduleSettingsSave();
|
||||
});
|
||||
|
||||
root.addEventListener("click", (event) => {
|
||||
const button = (event.target as Element).closest<HTMLElement>("[data-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === "toggle-history") { state.historyOpen = !state.historyOpen; renderApp(); }
|
||||
if (action === "close-history") { state.historyOpen = false; renderApp(); }
|
||||
if (action === "new-conversation") void newConversation().catch(showError);
|
||||
if (action === "select-conversation" && button.dataset.id) void selectConversation(button.dataset.id).catch(showError);
|
||||
if (action === "delete-conversation" && button.dataset.id) void removeConversation(button.dataset.id).catch(showError);
|
||||
if (action === "choose-model" && button.dataset.provider && button.dataset.model) chooseModel(button.dataset.provider, button.dataset.model);
|
||||
if (action === "configure-local" && button.dataset.provider) void configureLocal(button.dataset.provider).catch(showError);
|
||||
if (action === "probe-local" && button.dataset.provider) void probeLocal(button.dataset.provider).catch(showError);
|
||||
if (action === "delete-local" && button.dataset.provider) {
|
||||
const item = state.frontendProviders.find((candidate) => candidate.id === button.dataset.provider);
|
||||
if (item && window.confirm(`删除此浏览器中的 ${item.name} Credential?`)) void deleteLocalCredential(item.id).then(() => window.location.reload());
|
||||
}
|
||||
if (action === "reset-settings") { state.generationSettings = {...defaultGenerationSettings}; scheduleSettingsSave(); renderApp(); }
|
||||
if (action === "stop") state.streamController?.abort();
|
||||
if (action === "scroll-bottom") scrollBottom();
|
||||
if (action === "copy-message") {
|
||||
const index = Number(button.dataset.index);
|
||||
const message = state.conversation?.messages[index];
|
||||
if (message) void navigator.clipboard.writeText(messagePartText(message, "text")).then(() => {
|
||||
button.classList.add("copied");
|
||||
window.setTimeout(() => button.classList.remove("copied"), 1200);
|
||||
});
|
||||
}
|
||||
if (action === "regenerate-message") void regenerate(Number(button.dataset.index)).catch(showError);
|
||||
});
|
||||
|
||||
function showError(error: unknown) {
|
||||
window.alert(error instanceof Error ? error.message : "操作失败");
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
const id = conversationIdFromHash(window.location.hash);
|
||||
if (id && id !== state.conversation?.id && state.conversations.some((item) => item.id === id)) void selectConversation(id, "none").catch(showError);
|
||||
});
|
||||
window.addEventListener("offline", () => { state.offline = true; renderApp(); });
|
||||
window.addEventListener("online", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await flushPendingConversationChanges();
|
||||
await synchronizeOfflineConversationHistory();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Unable to synchronize offline history", error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
window.matchMedia("(min-width: 681px)").addEventListener("change", (event) => { state.historyOpen = event.matches; renderApp(); });
|
||||
|
||||
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=4", {scope: "/"}).catch((error) => console.error("Unable to register service worker", error));
|
||||
|
||||
renderApp();
|
||||
initialize().catch((error) => {
|
||||
state.loading = false;
|
||||
state.error = error instanceof Error ? error.message : "配置加载失败";
|
||||
renderApp();
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<!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="使用个人 Key Vault 凭据的轻量 AI 对话界面">
|
||||
<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>Xiteng Chat</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/icons/icon-192.png" sizes="192x192" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="stylesheet" href="/styles.css?__ASSET_VERSION__">
|
||||
<script type="module" src="/assets/client.js?__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,424 @@
|
||||
import {createHash, randomUUID} from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {convertToModelMessages, streamText, type UIMessage} from "ai";
|
||||
import {
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
getConversation,
|
||||
listConversations,
|
||||
saveConversationMessages,
|
||||
updateConversationSettings
|
||||
} 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 {StoredChatMessage} from "../lib/conversation-types";
|
||||
|
||||
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 encoder = new TextEncoder();
|
||||
|
||||
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 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)
|
||||
]);
|
||||
const identityKey = createHash("sha256").update(`${identity.issuer}\0${identity.sub}`).digest("hex").slice(0, 32);
|
||||
return json({providers, identityKey, profile});
|
||||
} catch (error) {
|
||||
return json({error: error instanceof Error ? error.message : "Provider configuration unavailable"}, errorStatus(error, 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 updated = updateConversationSettings(identity, id, await request.json());
|
||||
return updated ? json({updated: true}) : 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);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
return value.map((message) => {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("message is invalid");
|
||||
const record = message as StoredChatMessage;
|
||||
return {
|
||||
id: typeof record.id === "string" ? record.id : randomUUID(),
|
||||
role: record.role,
|
||||
parts: Array.isArray(record.parts) ? record.parts.filter((part) => part.type === "text" || part.type === "reasoning") : [],
|
||||
...(record.metadata ? {metadata: record.metadata} : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
if (!getConversation(identity, conversationId)) return json({error: "Conversation not found"}, 404);
|
||||
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 metadata = responseMetadata(input.providerId!.trim(), input.model!.trim(), startedAt, usage.outputTokens);
|
||||
const assistant: StoredChatMessage = {
|
||||
id: randomUUID(),
|
||||
role: "assistant",
|
||||
parts: [
|
||||
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
|
||||
...(text ? [{type: "text", text}] : [])
|
||||
],
|
||||
metadata: {custom: {response: metadata}}
|
||||
};
|
||||
saveConversationMessages(identity, conversationId, {providerId: input.providerId, model: input.model, messages: [...messages, assistant]});
|
||||
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));
|
||||
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 === "/api/health" && request.method === "GET") return json({status: "ok"});
|
||||
if (url.pathname === "/api/config" && request.method === "GET") return config(request);
|
||||
if (url.pathname === "/api/conversations") return conversations(request);
|
||||
const conversationMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/);
|
||||
if (conversationMatch) return conversation(request, decodeURIComponent(conversationMatch[1]));
|
||||
if (url.pathname === "/api/provider-test" && request.method === "POST") return providerTest(request);
|
||||
if (url.pathname === "/api/chat" && request.method === "POST") {
|
||||
server.timeout(request, 0);
|
||||
return chat(request);
|
||||
}
|
||||
if (url.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(url.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`xiteng-chat Bun server listening on ${server.url}`);
|
||||
@@ -0,0 +1,201 @@
|
||||
svg { 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; }
|
||||
.brand-mark, .welcome-mark { display: grid; place-items: center; color: #fff; background: #171717; font-weight: 800; letter-spacing: -0.08em; }
|
||||
.brand-mark { width: 30px; height: 30px; border-radius: 10px; font-size: 11px; }
|
||||
.offline-badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid rgba(163,56,45,.2); border-radius: 999px; padding: 4px 7px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 10px; font-weight: 650; letter-spacing: 0; white-space: nowrap; }
|
||||
.chat-controls { display: flex; align-items: center; justify-self: end; gap: 8px; }
|
||||
.header-button, .local-key-menu > summary, .generation-menu > summary { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.72); color: var(--text); text-decoration: none; }
|
||||
.header-button { padding: 0 11px; cursor: pointer; }
|
||||
.account-button { padding: 4px 10px 4px 5px; }
|
||||
.header-avatar { width: 26px; height: 26px; border-radius: 8px; object-fit: cover; background: var(--soft); }
|
||||
.header-button:hover, .local-key-menu > summary:hover, .generation-menu > summary:hover { background: #fff; border-color: rgba(23,23,23,.2); }
|
||||
.local-key-menu, .generation-menu { position: relative; }
|
||||
.local-key-menu > summary, .generation-menu > summary { width: 36px; justify-content: center; cursor: pointer; list-style: none; }
|
||||
.local-key-menu > summary::-webkit-details-marker, .generation-menu > summary::-webkit-details-marker { display: none; }
|
||||
.local-key-menu > div { position: absolute; z-index: 31; top: 43px; right: 0; display: grid; width: min(320px, calc(100vw - 24px)); max-height: min(520px, calc(100dvh - 80px)); overflow-y: auto; gap: 4px; border: 1px solid var(--line); border-radius: 12px; padding: 8px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); }
|
||||
.local-key-menu-title { padding: 5px 7px 8px; color: var(--muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.local-key-entry { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; padding: 7px; }
|
||||
.local-key-entry:hover { background: var(--soft); }
|
||||
.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: 2px; color: var(--muted); font-size: 9px; }
|
||||
.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; }
|
||||
.local-key-menu button { border: 0; border-radius: 8px; padding: 8px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
|
||||
.local-key-menu button:hover { background: var(--panel); }
|
||||
.local-key-menu button.dangerous { color: #b42318; }
|
||||
.generation-menu > div { position: absolute; z-index: 31; top: 43px; right: 0; display: grid; width: min(280px, calc(100vw - 24px)); gap: 10px; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); }
|
||||
.generation-menu > div > strong { font-size: 12px; }
|
||||
.generation-menu label { display: grid; gap: 5px; color: var(--muted); font-size: 10px; }
|
||||
.generation-menu select, .generation-menu input[type="number"] { min-width: 0; width: 100%; box-sizing: border-box; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: var(--bg); color: var(--text); }
|
||||
.generation-menu .generation-check { display: flex; align-items: center; gap: 7px; color: var(--text); }
|
||||
.generation-menu button { border: 0; border-radius: 8px; padding: 9px; background: var(--soft); color: var(--text); cursor: pointer; }
|
||||
.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 span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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 svg { transform: rotate(180deg); }
|
||||
.model-menu { position: absolute; z-index: 30; top: 43px; left: 0; width: min(430px, calc(100vw - 24px)); max-height: min(670px, calc(100dvh - 82px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 14px; padding: 8px; background: var(--panel); box-shadow: 0 18px 48px rgba(0,0,0,.15); backdrop-filter: blur(18px); }
|
||||
.model-search { position: sticky; z-index: 1; top: -8px; display: flex; height: 40px; align-items: center; gap: 8px; margin: -1px -1px 5px; padding: 0 9px; border-bottom: 1px solid var(--line); background: var(--panel); color: var(--muted); }
|
||||
.model-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); }
|
||||
.model-menu-group { display: grid; gap: 2px; padding: 5px 0 8px; }
|
||||
.model-menu-group + .model-menu-group { border-top: 1px solid var(--line); }
|
||||
.model-menu-group h3 { display: flex; align-items: center; gap: 5px; margin: 0; padding: 4px 8px; color: var(--muted); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.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-menu-empty { margin: 10px; color: var(--muted); text-align: center; }
|
||||
.state-card > .model-picker { display: inline-block; margin-bottom: 24px; text-align: left; }
|
||||
.state-card > .model-picker > summary { min-width: 260px; }
|
||||
.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-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; align-items: center; border-radius: 9px; }
|
||||
.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 { width: 26px; height: 26px; border-radius: 7px; color: var(--muted); opacity: 0; }
|
||||
.history-item:hover .history-delete, .history-item.active .history-delete { opacity: 1; }
|
||||
.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; scroll-behavior: smooth; 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%); margin: 0 auto; padding: 14px 0; }
|
||||
.user-message { display: flex; justify-content: flex-end; }
|
||||
.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; }
|
||||
.assistant-content { padding: 2px 4px; }
|
||||
.assistant-content .aui-md { color: var(--text); }
|
||||
.assistant-content .aui-md > :first-child { margin-top: 0; }
|
||||
.assistant-content .aui-md > :last-child { margin-bottom: 0; }
|
||||
.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; }
|
||||
.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%); }
|
||||
.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-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: 1fr 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); }
|
||||
.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 { 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; }
|
||||
.send-button { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 13px; background: var(--accent); color: var(--accent-text); cursor: pointer; }
|
||||
.send-button:disabled { cursor: default; opacity: .35; }
|
||||
.composer-note { margin: 8px 0 0; color: #90908a; font-size: 11px; }
|
||||
.composer-note.offline { color: var(--danger); }
|
||||
|
||||
.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; } }
|
||||
|
||||
@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); }
|
||||
.account-button { width: 36px; }
|
||||
.app-header { overflow: clip; }
|
||||
.brand > span:nth-child(2), .header-button span { display: none; }
|
||||
.offline-badge { padding: 4px 6px; font-size: 0; }
|
||||
.header-button { width: 36px; justify-content: center; padding: 0; }
|
||||
.provider-control select { max-width: 105px; }
|
||||
.model-picker > summary { min-width: 132px; max-width: 170px; }
|
||||
.model-menu { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); }
|
||||
.local-key-menu > div { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); }
|
||||
.generation-menu > div { 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; }
|
||||
.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)); }
|
||||
.composer-note { display: none; }
|
||||
}
|
||||
|
||||
@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%); }
|
||||
.header-button, .local-key-menu > summary, .local-key-menu > div, .generation-menu > summary, .generation-menu > div, .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; }
|
||||
.header-button:hover, .local-key-menu > summary:hover { background: #292a26; }
|
||||
.assistant-content pre { background: #080908; }
|
||||
.state-page { background: radial-gradient(circle at 50% 10%, #272824, var(--bg) 52%); }
|
||||
.brand-mark, .welcome-mark, .state-mark, .primary-link { background: #efefeb; color: #171717; }
|
||||
.loader { border-top-color: #efefeb; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
Reference in New Issue
Block a user