diff --git a/README.md b/README.md index 374d7f5..686e778 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ | Key Vault | 内网 | Authentik 身份 | 加密 Backend Credential、Provider Registry 与审计 | | Gitea | [gitea.xiteng.site](https://gitea.xiteng.site) | Authentik OAuth2 | 代码托管 | | HedgeDoc | [notes.xiteng.site](https://notes.xiteng.site) | Authentik OIDC | Markdown 协作 | -| Xiteng Chat | [chat.xiteng.site](https://chat.xiteng.site) | Authentik ForwardAuth | assistant-ui 聊天界面,支持双连接 Provider 与跨刷新聊天历史 | +| Xiteng Chat | [xiteng.site/chat](https://xiteng.site/chat/) | Authentik ForwardAuth | Vanilla TypeScript + Bun 聊天界面,支持 Backend/Frontend Provider 与跨刷新聊天历史 | | Code Server | [code.xiteng.site](https://code.xiteng.site) | Authentik ForwardAuth | 浏览器中的 VS Code 工作台 | | ComfyUI | [comfy.xiteng.site](https://comfy.xiteng.site) | Authentik ForwardAuth | 节点式图像生成工作台 | | InvokeAI | [invoke.xiteng.site](https://invoke.xiteng.site) | Authentik ForwardAuth | 图像生成、画布编辑与模型管理 | @@ -76,7 +76,7 @@ - **Authentik**: 隐藏的身份引擎,负责 OAuth2/OIDC、ForwardAuth、用户与策略存储;日常用户、用户组和应用权限管理统一在 `https://xiteng.site/admin` - **Key Vault**: 用 Authentik `(issuer, sub)` 关联用户,以 AES-256-GCM 信封加密保存 Backend Credential,并维护 Provider Registry;只有 `xiteng-chat` 服务端解析接口会短暂取得属于当前用户的明文 -- **Xiteng Chat**: 基于 assistant-ui 与 AI SDK;Backend Provider 由 Chat 服务端使用 Key Vault Credential 直连,Frontend Provider 由浏览器使用 IndexedDB 本地 Credential 直连;聊天记录按 Authentik `(issuer, sub)` 隔离并持久化到 `chat/data/chat.db`,不会保存 Credential 明文 +- **Xiteng Chat**: 基于 Vanilla TypeScript、Bun 与 AI SDK;Backend Provider 由 Chat 服务端使用 Key Vault Credential 直连,Frontend Provider 由浏览器使用 IndexedDB 本地 Credential 直连;聊天记录按 Authentik `(issuer, sub)` 隔离并持久化到 `chat/data/chat.db`,不会保存 Credential 明文 - **Portal**: Authentik ForwardAuth 保护 `/admin` 与 `/account`;`/admin` 只允许 `liooil`,并提供用户、用户组、密码恢复、会话注销和 `liuhome` 应用权限矩阵 - **公开目录**: `xiteng.site` 不做登录判断,只展示 Label 明确声明的公开元数据;受控服务在点击后执行 Authentik 或服务自身认证 @@ -132,7 +132,7 @@ homelab/ │ ├── components/ # assistant-ui 线程与页面壳层 │ ├── data/ # 用户聊天历史 SQLite,gitignore │ ├── Dockerfile -│ └── compose.yml # chat.xiteng.site / Authentik ForwardAuth +│ └── compose.yml # xiteng.site/chat / Authentik ForwardAuth ├── code-server/ │ ├── compose.yml │ ├── .env # 本地 UID/GID 等环境变量,gitignore diff --git a/ai-gateway/providers.json b/ai-gateway/providers.json index e278656..c4ca15f 100644 --- a/ai-gateway/providers.json +++ b/ai-gateway/providers.json @@ -408,14 +408,17 @@ "name": "Ollama", "api": "openai-completions", "connection": { - "type": "frontend", - "baseUrl": "http://127.0.0.1:11434/v1", + "type": "backend", + "baseUrl": "http://xiteng-ollama-provider-bridge:11434/v1", "proxy": null }, + "headers": { + "Host": "localhost:11434" + }, "auth": { "type": "none" }, - "defaultModel": "qwen3:8b" + "defaultModel": "qwen3.5:9b" }, { "id": "lm-studio", diff --git a/chat/build.ts b/chat/build.ts index c69f028..e0501d9 100644 --- a/chat/build.ts +++ b/chat/build.ts @@ -18,3 +18,8 @@ const version = Bun.hash(`${readFileSync("src/client.ts", "utf8")}\0${readFileSy writeFileSync("dist/index.html", readFileSync("src/index.html", "utf8").replaceAll("__ASSET_VERSION__", version)); cpSync("src/styles.css", "dist/styles.css"); cpSync("public", "dist", {recursive: true}); +mkdirSync("dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg/dynamic", {recursive: true}); +cpSync("node_modules/mathjax/tex-svg-nofont.js", "dist/assets/mathjax/4.1.3/tex-svg-nofont.js"); +cpSync("node_modules/@mathjax/mathjax-newcm-font/svg.js", "dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg.js"); +cpSync("node_modules/@mathjax/mathjax-newcm-font/svg/dynamic", "dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg/dynamic", {recursive: true}); +cpSync("node_modules/mathjax/input/tex/extensions", "dist/assets/mathjax/4.1.3/input/tex/extensions", {recursive: true}); diff --git a/chat/bun.lock b/chat/bun.lock index 53d69fc..f5282df 100644 --- a/chat/bun.lock +++ b/chat/bun.lock @@ -11,12 +11,14 @@ "@ai-sdk/openai-compatible": "3.0.28", "ai": "7.0.58", "dompurify": "3.2.6", + "fflate": "0.8.2", "marked": "15.0.12", "node-fetch": "3.3.2", "proxy-agent": "8.0.2", }, "devDependencies": { "@types/bun": "1.3.5", + "mathjax": "4.1.3", "typescript": "5.9.3", }, }, @@ -36,6 +38,8 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.25", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA=="], + "@mathjax/mathjax-newcm-font": ["@mathjax/mathjax-newcm-font@4.1.3", "", {}, "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], @@ -78,6 +82,8 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], "get-uri": ["get-uri@8.0.1", "", { "dependencies": { "basic-ftp": "^5.3.1", "data-uri-to-buffer": "8.0.0", "debug": "^4.3.4" } }, "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww=="], @@ -94,6 +100,8 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "mathjax": ["mathjax@4.1.3", "", { "dependencies": { "@mathjax/mathjax-newcm-font": "^4.1.3" } }, "sha512-BN/8Pkgn7G1pIDYJqd9md+JHsE/jydSYbyOZnSdSA0WziuVO8mRxdYiWFumkVVly/8U+hm9DpIIoWuvySverzw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], diff --git a/chat/compose.yml b/chat/compose.yml index c3fbdef..373f781 100644 --- a/chat/compose.yml +++ b/chat/compose.yml @@ -66,6 +66,7 @@ services: BRIDGE_MODE: network BRIDGE_SOCKET: /run/provider-proxy/ollama.sock LISTEN_PORT: "11434" + UPSTREAM_HTTP_HOST: localhost:11434 volumes: - ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro - ./run:/run/provider-proxy @@ -93,8 +94,10 @@ services: KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac CHAT_DATABASE_PATH: /data/chat.db PORTAL_URL: http://xiteng-site:8080 + PUBLIC_PROVIDER_CATALOG_FILE: /app/providers.json volumes: - ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro + - ../ai-gateway/providers.json:/app/providers.json:ro - ./data:/data security_opt: - no-new-privileges:true @@ -118,13 +121,37 @@ services: - "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.middlewares=xiteng-chat-scheme" - "traefik.http.routers.xiteng-chat.priority=300" + - "traefik.http.routers.xiteng-chat-api.rule=Host(`xiteng.site`) && PathPrefix(`/chat/api/`)" + - "traefik.http.routers.xiteng-chat-api.entrypoints=websecure" + - "traefik.http.routers.xiteng-chat-api.tls=true" + - "traefik.http.routers.xiteng-chat-api.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-chat-api.service=xiteng-chat" + - "traefik.http.routers.xiteng-chat-api.middlewares=xiteng-chat-scheme,xiteng-chat-auth" + - "traefik.http.routers.xiteng-chat-api.priority=320" + - "traefik.http.routers.xiteng-chat-public-api.rule=Host(`xiteng.site`) && Path(`/chat/api/public-config`)" + - "traefik.http.routers.xiteng-chat-public-api.entrypoints=websecure" + - "traefik.http.routers.xiteng-chat-public-api.tls=true" + - "traefik.http.routers.xiteng-chat-public-api.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-chat-public-api.service=xiteng-chat" + - "traefik.http.routers.xiteng-chat-public-api.middlewares=xiteng-chat-scheme" + - "traefik.http.routers.xiteng-chat-public-api.priority=330" - "traefik.http.routers.xiteng-chat-http.rule=Host(`xiteng.site`) && (Path(`/chat`) || PathPrefix(`/chat/`))" - "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" + - "traefik.http.routers.xiteng-chat-http.middlewares=xiteng-chat-scheme" - "traefik.http.routers.xiteng-chat-http.priority=300" + - "traefik.http.routers.xiteng-chat-api-http.rule=Host(`xiteng.site`) && PathPrefix(`/chat/api/`)" + - "traefik.http.routers.xiteng-chat-api-http.entrypoints=web" + - "traefik.http.routers.xiteng-chat-api-http.service=xiteng-chat" + - "traefik.http.routers.xiteng-chat-api-http.middlewares=xiteng-chat-scheme,xiteng-chat-auth" + - "traefik.http.routers.xiteng-chat-api-http.priority=320" + - "traefik.http.routers.xiteng-chat-public-api-http.rule=Host(`xiteng.site`) && Path(`/chat/api/public-config`)" + - "traefik.http.routers.xiteng-chat-public-api-http.entrypoints=web" + - "traefik.http.routers.xiteng-chat-public-api-http.service=xiteng-chat" + - "traefik.http.routers.xiteng-chat-public-api-http.middlewares=xiteng-chat-scheme" + - "traefik.http.routers.xiteng-chat-public-api-http.priority=330" - "xiteng.site.cache.chat.enabled=true" - "xiteng.site.cache.chat.routers=xiteng-chat,xiteng-chat-http" - "xiteng.site.cache.chat.paths=/chat/assets/,/chat/icons/" @@ -132,12 +159,12 @@ services: - "xiteng.site.cache.chat.stale-while-revalidate=86400" - "xiteng.site.component.chat.enabled=true" - "xiteng.site.component.chat.name=Xiteng Chat" - - "xiteng.site.component.chat.description=基于 Vanilla TypeScript、Bun 与个人 Key Vault 的轻量 AI 对话界面。" + - "xiteng.site.component.chat.description=本地优先的 AI 对话界面;无需登录即可使用浏览器 Provider,登录后启用个人同步与 Key Vault。" - "xiteng.site.component.chat.section=services" - "xiteng.site.component.chat.category=AI" - "xiteng.site.component.chat.url=https://xiteng.site/chat/" - - "xiteng.site.component.chat.access=sso" - - "xiteng.site.component.chat.access-label=需要登录" + - "xiteng.site.component.chat.access=public" + - "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" diff --git a/chat/history.test.mjs b/chat/history.test.mjs index fa26053..1a3f8eb 100644 --- a/chat/history.test.mjs +++ b/chat/history.test.mjs @@ -4,6 +4,9 @@ import http from "node:http"; import {tmpdir} from "node:os"; import path from "node:path"; import {spawn} from "node:child_process"; +import {Database} from "bun:sqlite"; +import {createHash} from "node:crypto"; +import {createMessageObject} from "./lib/message-object.ts"; const root = process.cwd(); const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "xiteng-chat-history-test-")); @@ -11,6 +14,28 @@ const databasePath = path.join(temporaryDirectory, "chat.db"); const tokenPath = path.join(temporaryDirectory, "service-token"); writeFileSync(tokenPath, "history-test-token"); +const legacyDatabase = new Database(databasePath, {create: true}); +legacyDatabase.run(` + CREATE TABLE chat_conversation ( + id TEXT PRIMARY KEY, owner_issuer TEXT NOT NULL, owner_sub TEXT NOT NULL, title TEXT NOT NULL, + provider_id TEXT NOT NULL, model TEXT NOT NULL, settings_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE chat_message ( + conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, + id TEXT NOT NULL, ordinal INTEGER NOT NULL, role TEXT NOT NULL, parts_json TEXT NOT NULL, + created_at TEXT NOT NULL, PRIMARY KEY (conversation_id, id), UNIQUE (conversation_id, ordinal) + ); +`); +const legacyTimestamp = new Date().toISOString(); +legacyDatabase.query("INSERT INTO chat_conversation VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("legacy-conversation", "https://auth.xiteng.site", "legacy-sub", "legacy/chat", "openai", "gpt-legacy", "{}", legacyTimestamp, legacyTimestamp); +legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)") + .run("legacy-conversation", "legacy-user", 0, "user", JSON.stringify([{type: "text", text: "legacy question"}]), legacyTimestamp); +legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)") + .run("legacy-conversation", "legacy-assistant", 1, "assistant", JSON.stringify([{type: "text", text: "legacy answer"}]), legacyTimestamp); +legacyDatabase.close(); + function availablePort() { return new Promise((resolve, reject) => { const server = http.createServer(); @@ -48,7 +73,7 @@ async function startServer() { }, stdio: "ignore" }); - await waitForServer(`http://127.0.0.1:${port}/api/health`, child); + await waitForServer(`http://127.0.0.1:${port}/chat/api/health`, child); return {child, origin: `http://127.0.0.1:${port}`}; } @@ -61,7 +86,7 @@ function stopServer(child) { } async function api(origin, identity, pathname, init = {}) { - const response = await fetch(`${origin}${pathname}`, { + const response = await fetch(`${origin}/chat${pathname}`, { ...init, headers: { "X-Authentik-Username": identity.username, @@ -76,45 +101,222 @@ async function api(origin, identity, pathname, init = {}) { const owner = {username: "history-owner", sub: "owner-sub"}; const other = {username: "history-other", sub: "other-sub"}; +const legacyOwner = {username: "history-legacy", sub: "legacy-sub"}; +const distributedOwner = {username: "distributed-owner", sub: "distributed-sub"}; +const localRepositoryOwner = {username: "local-repository-owner", sub: "local-repository-sub"}; +const untitledOwner = {username: "untitled-owner", sub: "untitled-sub"}; let server; try { server = await startServer(); + const publicConfigResponse = await fetch(`${server.origin}/chat/api/public-config`); + assert.equal(publicConfigResponse.status, 200); + const publicConfig = await publicConfigResponse.json(); + assert.ok(publicConfig.providers.length > 0); + assert.ok(publicConfig.providers.every((provider) => provider.connection.type === "frontend" && provider.credentials.length === 0)); + const anonymousPrivateConfig = await fetch(`${server.origin}/chat/api/config`); + assert.equal(anonymousPrivateConfig.status, 401); + const migratedLegacy = await api(server.origin, legacyOwner, "/api/conversations/legacy-conversation"); + assert.equal(migratedLegacy.response.status, 200); + assert.equal(migratedLegacy.payload.conversation.name, "legacy/chat"); + assert.equal(migratedLegacy.payload.conversation.messages.length, 2); + assert.ok(migratedLegacy.payload.conversation.messages.every((message) => message.id.startsWith("sha256:"))); + assert.equal(migratedLegacy.payload.conversation.messages[1].parentMessageId, migratedLegacy.payload.conversation.messages[0].id); const initialSettings = {reasoning: "low", showReasoningSummary: false, temperature: null, maxOutputTokens: null}; const createdResult = await api(server.origin, owner, "/api/conversations", { method: "POST", - body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings}) + body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat"}) }); assert.equal(createdResult.response.status, 201); assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings); - 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(createdResult.payload.conversation.name, "work/chat"); + assert.equal(createdResult.payload.conversation.headMessageId, null); + const untitledResult = await api(server.origin, untitledOwner, "/api/conversations", { + method: "POST", + body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings}) }); - assert.equal(savedResult.response.status, 200); - assert.equal(savedResult.payload.conversation.title, "persistent question"); - assert.equal(savedResult.payload.conversation.messageCount, 2); + assert.equal(untitledResult.response.status, 201); + assert.equal(untitledResult.payload.conversation.name, ""); + const untitledUser = await api(server.origin, untitledOwner, `/api/conversations/${untitledResult.payload.conversation.id}/messages`, { + method: "POST", + body: JSON.stringify({ + id: "untitled-user", + expectedHeadId: null, + parentMessageId: null, + role: "user", + parts: [{type: "text", text: "这段文字不能直接成为标题"}], + origin: {type: "user"}, + completion: {status: "complete"} + }) + }); + assert.equal(untitledUser.response.status, 201); + assert.equal(untitledUser.payload.conversation.name, ""); + const conversationId = createdResult.payload.conversation.id; + const userResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { + method: "POST", + body: JSON.stringify({ + id: "user-1", + expectedHeadId: null, + parentMessageId: null, + role: "user", + parts: [{type: "text", text: "persistent question"}], + origin: {type: "user", clientId: "history-test"}, + completion: {status: "complete"} + }) + }); + assert.equal(userResult.response.status, 201); + assert.equal(userResult.payload.conversation.headMessageId, "user-1"); + const responseMetadata = {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20}; + const assistantResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { + method: "POST", + body: JSON.stringify({ + id: "assistant-1", + expectedHeadId: "user-1", + parentMessageId: "user-1", + role: "assistant", + parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}], + origin: {type: "model", providerId: "openai", model: "gpt-test", attemptId: "attempt-1"}, + completion: {status: "complete"}, + metadata: {custom: {response: responseMetadata}} + }) + }); + assert.equal(assistantResult.response.status, 201); + assert.equal(assistantResult.payload.conversation.messageCount, 2); + const conflict = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { + method: "POST", + body: JSON.stringify({id: "conflict", expectedHeadId: "user-1", parentMessageId: "user-1", role: "assistant", parts: [], origin: {type: "legacy"}, completion: {status: "complete"}}) + }); + assert.equal(conflict.response.status, 409); const updatedSettings = {reasoning: "high", showReasoningSummary: true, temperature: 0.7, maxOutputTokens: 4096}; const settingsResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, { method: "PATCH", - body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: updatedSettings}) + body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: updatedSettings, name: "work/chat/main"}) }); assert.equal(settingsResult.response.status, 200); + assert.equal(settingsResult.payload.conversation.name, "work/chat/main"); + + const branchResult = await api(server.origin, owner, "/api/conversations", { + method: "POST", + body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat/branch", headMessageId: "user-1"}) + }); + assert.equal(branchResult.response.status, 201); + assert.equal(branchResult.payload.conversation.messageCount, 1); + assert.equal(branchResult.payload.conversation.messages[0].id, "user-1"); + + const namespace = createHash("sha256").update(`https://auth.xiteng.site\0${distributedOwner.sub}`).digest("hex").slice(0, 32); + const objectTimestamp = new Date().toISOString(); + const distributedMessage = await createMessageObject({ + parentMessageId: null, + role: "user", + parts: [{type: "text", text: "local-first message"}], + origin: {type: "user", clientId: "test-replica"}, + completion: {status: "complete"}, + createdAt: objectTimestamp, + completedAt: objectTimestamp + }, namespace); + const distributedRef = { + conversationId: "distributed-conversation", + expectedHeadMessageId: null, + expectedHeadVersion: 0, + expectedMetadataVersion: 0, + headMessageId: distributedMessage.id, + name: "", + providerId: "openai", + model: "gpt-test", + generationSettings: initialSettings, + createdAt: objectTimestamp, + updatedAt: objectTimestamp + }; + const pushed = await api(server.origin, distributedOwner, "/api/sync/push", { + method: "POST", + body: JSON.stringify({objects: [distributedMessage], refs: [distributedRef]}) + }); + assert.equal(pushed.response.status, 200); + assert.equal(pushed.payload.refs[0].status, "ok"); + assert.equal(pushed.payload.refs[0].ref.headMessageId, distributedMessage.id); + const fetched = await api(server.origin, distributedOwner, "/api/sync/fetch", { + method: "POST", + body: JSON.stringify({haveObjectIds: []}) + }); + assert.equal(fetched.response.status, 200); + assert.equal(fetched.payload.objects[0].id, distributedMessage.id); + assert.equal(fetched.payload.refs[0].id, "distributed-conversation"); + assert.equal(fetched.payload.refs[0].name, ""); + const fetchedWithHave = await api(server.origin, distributedOwner, "/api/sync/fetch", { + method: "POST", + body: JSON.stringify({haveObjectIds: [distributedMessage.id]}) + }); + assert.deepEqual(fetchedWithHave.payload.objects, []); + const alternateMessage = await createMessageObject({ + parentMessageId: null, + role: "user", + parts: [{type: "text", text: "alternate root"}], + origin: {type: "user", clientId: "test-replica", sourceMessageId: distributedMessage.id}, + completion: {status: "complete"}, + createdAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString(), + completedAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString() + }, namespace); + const movedRef = await api(server.origin, distributedOwner, "/api/sync/push", { + method: "POST", + body: JSON.stringify({ + objects: [alternateMessage], + refs: [{ + ...distributedRef, + expectedHeadMessageId: distributedMessage.id, + expectedHeadVersion: pushed.payload.refs[0].ref.headVersion, + expectedMetadataVersion: pushed.payload.refs[0].ref.metadataVersion, + headMessageId: alternateMessage.id + }] + }) + }); + assert.equal(movedRef.payload.refs[0].status, "ok"); + const graphFetch = await api(server.origin, distributedOwner, "/api/sync/fetch", { + method: "POST", + body: JSON.stringify({haveObjectIds: []}) + }); + assert.deepEqual(new Set(graphFetch.payload.objects.map((message) => message.id)), new Set([distributedMessage.id, alternateMessage.id])); + const localRepositoryId = "local:12345678-1234-4234-8234-123456789abc"; + const localRepositoryMessage = await createMessageObject({ + parentMessageId: null, + role: "user", + parts: [{type: "text", text: "created before login"}], + origin: {type: "user", clientId: "12345678-1234-4234-8234-123456789abc"}, + completion: {status: "complete"}, + createdAt: objectTimestamp, + completedAt: objectTimestamp + }, localRepositoryId); + const localRepositoryPush = await api(server.origin, localRepositoryOwner, "/api/sync/push", { + method: "POST", + body: JSON.stringify({ + repositoryId: localRepositoryId, + objects: [localRepositoryMessage], + refs: [{...distributedRef, conversationId: "pre-login-conversation", headMessageId: localRepositoryMessage.id}] + }) + }); + assert.equal(localRepositoryPush.response.status, 200); + assert.equal(localRepositoryPush.payload.refs[0].status, "ok"); + const rejectedHash = await api(server.origin, distributedOwner, "/api/sync/push", { + method: "POST", + body: JSON.stringify({objects: [{...distributedMessage, id: `sha256:${"0".repeat(64)}`}], refs: []}) + }); + assert.equal(rejectedHash.response.status, 400); + const rejectedLease = await api(server.origin, distributedOwner, "/api/sync/push", { + method: "POST", + body: JSON.stringify({objects: [], refs: [{...distributedRef, expectedHeadVersion: 0, headMessageId: null}]}) + }); + assert.equal(rejectedLease.payload.refs[0].status, "conflict"); await stopServer(server.child); server = await startServer(); const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`); assert.equal(restored.response.status, 200); - assert.deepEqual(restored.payload.conversation.messages, messages); + assert.equal(restored.payload.conversation.messages.length, 2); + assert.ok(restored.payload.conversation.messages.every((message) => message.id.startsWith("sha256:"))); + assert.equal(restored.payload.conversation.messages[1].parentMessageId, restored.payload.conversation.messages[0].id); + assert.deepEqual(restored.payload.conversation.messages[1].metadata.custom.response, responseMetadata); assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings); const ownerList = await api(server.origin, owner, "/api/conversations"); - assert.equal(ownerList.payload.conversations.length, 1); - assert.equal(ownerList.payload.conversations[0].id, conversationId); + assert.equal(ownerList.payload.conversations.length, 2); const otherList = await api(server.origin, other, "/api/conversations"); assert.deepEqual(otherList.payload.conversations, []); @@ -123,6 +325,7 @@ try { assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 204); assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`)).response.status, 404); + assert.equal((await api(server.origin, owner, `/api/conversations/${branchResult.payload.conversation.id}`)).payload.conversation.messages[0].parts[0].text, "persistent question"); console.log("Chat history API tests passed"); } finally { if (server) await stopServer(server.child); diff --git a/chat/lib/conversation-client.ts b/chat/lib/conversation-client.ts index cd1e9c8..36c4c7a 100644 --- a/chat/lib/conversation-client.ts +++ b/chat/lib/conversation-client.ts @@ -1,15 +1,50 @@ -import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types"; +import type { + Conversation, + ConversationRefState, + ConversationSummary, + MessageCompletion, + MessageOrigin, + RepositoryFetch, + StoredChatMessage +} from "./conversation-types"; import type {GenerationSettings} from "./generation-settings"; import { + applyRepositoryFetch, + applyRepositoryPushResults, cacheConversation, cacheConversationSummaries, + commitLocalMessage, + createLocalConversation, + deleteLocalConversation, + listCachedObjectIds, listPendingConversationChanges, loadCachedConversation, loadCachedConversationSummaries, + moveLocalConversationHead, queueConversationChange, - removeCachedConversation, - removePendingConversationChange + queueLocalRefUpdate, + removePendingConversationChange, + repositoryPushPayload } from "./offline-history"; +import {repositoryPushBatches} from "./repository-push-batches"; + +const chatBasePath = "/chat"; +const chatApi = (pathname: string) => `${chatBasePath}${pathname}`; + +export type MessageCommitInput = { + id: string; + expectedHeadId: string | null; + parentMessageId: string | null; + role: StoredChatMessage["role"]; + parts: StoredChatMessage["parts"]; + origin: MessageOrigin; + completion: MessageCompletion; + createdAt: string; + completedAt: string; + metadata?: StoredChatMessage["metadata"]; + providerId?: string; + model?: string; +}; class ConversationHttpError extends Error { constructor(message: string, readonly status: number) { @@ -33,127 +68,132 @@ async function conversationRequest(path: string, init?: RequestInit) { return payload as T; } -function isNetworkFailure(error: unknown) { - return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine); +export function listConversationHistory() { + return loadCachedConversationSummaries(); } -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, + name: string, + headMessageId: string | null = null, + messages: StoredChatMessage[] = [] +) { + const timestamp = new Date().toISOString(); + const conversation: Conversation = { + id: crypto.randomUUID(), + name, + headMessageId, + upstreamHeadMessageId: null, + providerId, + model, + generationSettings, + headVersion: 0, + metadataVersion: 0, + messageCount: messages.length, + createdAt: timestamp, + updatedAt: timestamp, + messages + }; + return createLocalConversation(conversation); } -export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) { - const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", { - method: "POST", - body: JSON.stringify({providerId, model, generationSettings}) - }); +export async function getConversationHistory(id: string) { + const cached = await loadCachedConversation(id); + if (!cached) throw new Error("Local conversation is unavailable; fetch may still be in progress"); + return cached; +} + +export async function updateConversationHistory( + id: string, + providerId: string, + model: string, + generationSettings: GenerationSettings, + name?: string +) { + const cached = await getConversationHistory(id); + const updated: Conversation = { + ...cached, + providerId, + model, + generationSettings, + ...(name === undefined ? {} : {name}), + updatedAt: new Date().toISOString() + }; + return queueLocalRefUpdate(updated); +} + +export async function commitConversationMessage(conversationId: string, input: MessageCommitInput) { + const cached = await getConversationHistory(conversationId); + if (cached.headMessageId !== input.expectedHeadId || input.parentMessageId !== input.expectedHeadId) throw new Error("Local conversation head changed"); + const message: StoredChatMessage = { + id: input.id, + parentMessageId: input.parentMessageId, + role: input.role, + parts: input.parts, + origin: input.origin, + completion: input.completion, + createdAt: input.createdAt, + completedAt: input.completedAt, + ...(input.metadata ? {metadata: input.metadata} : {}) + }; + return commitLocalMessage(conversationId, message); +} + +export function moveConversationHead(conversationId: string, headMessageId: string | null) { + return moveLocalConversationHead(conversationId, headMessageId); +} + +// Compatibility for queued operations produced by an older client. +export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) { + const body = JSON.stringify({providerId, model, messages}); + const payload = await conversationRequest<{conversation: Conversation}>(chatApi(`/api/conversations/${encodeURIComponent(id)}`), {method: "PUT", body}); await cacheConversation(payload.conversation); return payload.conversation; } -export async function 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(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"}); - } catch (error) { - if (!isNetworkFailure(error)) throw error; - await queueConversationChange({conversationId: id, method: "DELETE"}); - } - await removeCachedConversation(id); + await deleteLocalConversation(id); } export async function flushPendingConversationChanges() { const pending = await listPendingConversationChanges(); for (const change of pending) { + const requestPath = change.requestPath || `/api/conversations/${encodeURIComponent(change.conversationId)}`; try { - await conversationRequest(`/api/conversations/${encodeURIComponent(change.conversationId)}`, { - method: change.method, - body: change.body - }); + await conversationRequest(chatApi(requestPath), {method: change.method, body: change.body}); await removePendingConversationChange(change.cacheKey); } catch (error) { if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) { await removePendingConversationChange(change.cacheKey); continue; } - 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); +export async function synchronizeConversationRepository() { + await flushPendingConversationChanges(); + const push = await repositoryPushPayload(); + let conflicts = 0; + if (push.refs.length || push.objects.length) { + for (const batch of repositoryPushBatches(push)) { + const pushed = await conversationRequest<{ + refs: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>; + }>(chatApi("/api/sync/push"), {method: "POST", body: JSON.stringify(batch)}); + await applyRepositoryPushResults(pushed.refs); + conflicts += pushed.refs.filter((result) => result.status === "conflict").length; } - }; - await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker())); - return history; + } + const haveObjectIds = await listCachedObjectIds(); + const fetched = await conversationRequest(chatApi("/api/sync/fetch"), { + method: "POST", + body: JSON.stringify({haveObjectIds}) + }); + await applyRepositoryFetch(fetched); + return {summaries: await loadCachedConversationSummaries(), fetchedAt: fetched.fetchedAt, conflicts}; } + +export const synchronizeOfflineConversationHistory = synchronizeConversationRepository; diff --git a/chat/lib/conversation-title.test.mjs b/chat/lib/conversation-title.test.mjs new file mode 100644 index 0000000..e50ecaf --- /dev/null +++ b/chat/lib/conversation-title.test.mjs @@ -0,0 +1,21 @@ +import {describe, expect, test} from "bun:test"; +import {conversationTitlePrompt, normalizeGeneratedConversationTitle} from "./conversation-title.ts"; + +describe("AI conversation titles", () => { + test("builds a bounded prompt from the first messages", () => { + const prompt = conversationTitlePrompt([ + {role: "user", parts: [{type: "text", text: "如何优化流式 Markdown?"}]}, + {role: "assistant", parts: [{type: "text", text: "可以按稳定块增量渲染。"}]} + ]); + expect(prompt).toContain("用户:如何优化流式 Markdown?"); + expect(prompt).toContain("助手:可以按稳定块增量渲染。"); + }); + + test("removes common model formatting and unsafe path separators", () => { + expect(normalizeGeneratedConversationTitle("## 标题:\“流式 Markdown / 渲染优化\”\n说明")).toBe("流式 Markdown / 渲染优化"); + }); + + test("allows an empty result to remain untitled", () => { + expect(normalizeGeneratedConversationTitle("no title\n")).toBe(""); + }); +}); diff --git a/chat/lib/conversation-title.ts b/chat/lib/conversation-title.ts new file mode 100644 index 0000000..5c961d3 --- /dev/null +++ b/chat/lib/conversation-title.ts @@ -0,0 +1,37 @@ +import type {StoredChatMessage} from "./conversation-types"; + +export const untitledConversationLabel = "未命名会话"; + +function messageText(message: StoredChatMessage) { + return message.parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => String(part.text)) + .join("\n") + .trim(); +} + +export function conversationTitlePrompt(messages: StoredChatMessage[]) { + const transcript = messages + .filter((message) => message.role === "user" || message.role === "assistant") + .slice(0, 4) + .map((message) => `${message.role === "user" ? "用户" : "助手"}:${messageText(message).slice(0, 2000)}`) + .filter((line) => !line.endsWith(":")) + .join("\n\n"); + return `请为下面的对话生成一个简洁、具体的中文标题。只输出标题,不要解释,不要加引号、Markdown 或“标题:”前缀。标题不超过 30 个字符,不要使用斜杠。\n\n${transcript}`; +} + +export function normalizeGeneratedConversationTitle(value: string) { + let title = value + .replace(/[\s\S]*?<\/think>/gi, "") + .trim() + .split(/\r?\n/)[0] + .replace(/^#{1,6}\s*/, "") + .replace(/^(?:标题|title)\s*[::]\s*/i, "") + .replace(/^[`'“”‘’\"]+|[`'“”‘’\"]+$/g, "") + .replace(/[\u0000-\u001f]/g, " ") + .replace(/\s+/g, " ") + .replaceAll("/", "/") + .trim(); + title = [...title].slice(0, 60).join("").trim(); + return title; +} diff --git a/chat/lib/conversation-types.ts b/chat/lib/conversation-types.ts index 96385a3..9029b9f 100644 --- a/chat/lib/conversation-types.ts +++ b/chat/lib/conversation-types.ts @@ -8,24 +8,101 @@ export type ResponseMetadata = { tokensPerSecond: number | null; }; +export type MessageOrigin = + | {type: "user"; clientId?: string; sourceMessageId?: string} + | {type: "manual"; clientId?: string; sourceMessageId?: string} + | {type: "model"; providerId: string; model: string; attemptId: string} + | {type: "system"; source: string} + | {type: "legacy"}; + +export type MessageCompletion = { + status: "complete" | "partial"; + reason?: "stop" | "user-cancelled" | "connection-lost" | "provider-error" | "timeout"; +}; + export type StoredChatMessage = { id: string; + parentMessageId: string | null; role: "system" | "user" | "assistant"; parts: Array & {type: string}>; + origin: MessageOrigin; + completion: MessageCompletion; + createdAt: string; + completedAt: string; metadata?: {custom?: {response?: ResponseMetadata}}; }; export type ConversationSummary = { id: string; - title: string; + name: string; + headMessageId: string | null; providerId: string; model: string; messageCount: number; createdAt: string; updatedAt: string; + upstreamHeadMessageId?: string | null; + headVersion?: number; + metadataVersion?: number; }; export type Conversation = ConversationSummary & { generationSettings: GenerationSettings; messages: StoredChatMessage[]; }; + +export type WorkingItemKind = "user-draft" | "assistant-stream"; +export type WorkingItemStatus = "editing" | "streaming" | "interrupted" | "failed"; + +export type WorkingItem = { + id: string; + conversationId: string; + kind: WorkingItemKind; + observedHeadId: string | null; + editSourceMessageId?: string; + messageRole?: "user" | "assistant"; + requestAssistantReply?: boolean; + incompleteTargetAction?: "interrupt" | "append"; + parts: StoredChatMessage["parts"]; + status: WorkingItemStatus; + attemptId?: string; + providerId?: string; + model?: string; + failureReason?: MessageCompletion["reason"]; + metadata?: StoredChatMessage["metadata"]; + createdAt: string; + updatedAt: string; +}; + +export type ConversationRefState = { + id: string; + name: string; + headMessageId: string | null; + providerId: string; + model: string; + generationSettings: GenerationSettings; + headVersion: number; + metadataVersion: number; + createdAt: string; + updatedAt: string; +}; + +export type RepositoryFetch = { + refs: ConversationRefState[]; + objects: StoredChatMessage[]; + fetchedAt: string; +}; + +export type RepositoryRefUpdate = { + conversationId: string; + expectedHeadMessageId: string | null; + expectedHeadVersion: number; + expectedMetadataVersion: number; + headMessageId: string | null; + name: string; + providerId: string; + model: string; + generationSettings: GenerationSettings; + createdAt: string; + updatedAt: string; +}; diff --git a/chat/lib/conversations.ts b/chat/lib/conversations.ts index c7af41a..505d45a 100644 --- a/chat/lib/conversations.ts +++ b/chat/lib/conversations.ts @@ -1,14 +1,178 @@ -import {randomUUID} from "node:crypto"; +import {createHash, randomUUID} from "node:crypto"; import {mkdirSync} from "node:fs"; import path from "node:path"; import {Database} from "bun:sqlite"; import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings"; import type {ChatIdentity} from "./key-vault"; -import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "./conversation-types"; +import type { + Conversation, + ConversationRefState, + ConversationSummary, + MessageCompletion, + MessageOrigin, + ResponseMetadata, + RepositoryFetch, + RepositoryRefUpdate, + StoredChatMessage +} from "./conversation-types"; +import {canonicalMessage} from "./message-object"; const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db"; let database: Database | undefined; +type ConversationRow = { + id: string; + name: string; + head_message_id: string | null; + provider_id: string; + model: string; + settings_json: string; + head_version: number; + metadata_version: number; + message_count: number; + created_at: string; + updated_at: string; +}; + +type MessageRow = { + id: string; + parent_message_id: string | null; + role: StoredChatMessage["role"]; + parts_json: string; + origin_json: string; + completion_json: string; + metadata_json: string; + depth: number; + created_at: string; + completed_at: string; +}; + +function now() { + return new Date().toISOString(); +} + +function columns(opened: Database, table: string) { + return opened.query(`PRAGMA table_info(${table})`).all() as Array<{name: string}>; +} + +function migrateLegacyMessages(opened: Database) { + const conversations = opened.query(` + SELECT id, owner_issuer, owner_sub FROM chat_conversation + WHERE head_message_id IS NULL + AND EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id) + `).all() as Array<{id: string; owner_issuer: string; owner_sub: string}>; + if (!conversations.length) return; + opened.run("BEGIN IMMEDIATE"); + try { + const existingNode = opened.query("SELECT id FROM chat_message_node WHERE id = ?"); + const insertNode = opened.query(` + INSERT INTO chat_message_node ( + id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, + completion_json, metadata_json, depth, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const updateHead = opened.query("UPDATE chat_conversation SET head_message_id = ? WHERE id = ?"); + for (const conversation of conversations) { + const messages = opened.query(` + SELECT id, role, parts_json, created_at FROM chat_message + WHERE conversation_id = ? ORDER BY ordinal + `).all(conversation.id) as Array<{id: string; role: StoredChatMessage["role"]; parts_json: string; created_at: string}>; + let parentId: string | null = null; + messages.forEach((legacy, depth) => { + let id = legacy.id; + if (existingNode.get(id)) id = randomUUID(); + const parsed = JSON.parse(legacy.parts_json) as StoredChatMessage["parts"]; + const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object"); + const parts = parsed.filter((part) => part.type !== "data-response-metadata"); + const metadata = metadataPart ? {custom: {response: metadataPart.data as ResponseMetadata}} : {}; + insertNode.run( + id, + conversation.owner_issuer, + conversation.owner_sub, + parentId, + legacy.role, + JSON.stringify(parts), + JSON.stringify({type: "legacy"}), + JSON.stringify({status: "complete"}), + JSON.stringify(metadata), + depth, + legacy.created_at, + legacy.created_at + ); + parentId = id; + }); + updateHead.run(parentId, conversation.id); + } + opened.run("COMMIT"); + } catch (error) { + opened.run("ROLLBACK"); + throw error; + } +} + +function migrateConversationHeadsToContentObjects(opened: Database) { + const conversations = opened.query(` + SELECT id, owner_issuer, owner_sub, head_message_id + FROM chat_conversation + WHERE head_message_id IS NOT NULL AND head_message_id NOT LIKE 'sha256:%' + `).all() as Array<{id: string; owner_issuer: string; owner_sub: string; head_message_id: string}>; + if (!conversations.length) return; + opened.run("BEGIN IMMEDIATE"); + try { + const read = opened.query(` + SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, + depth, created_at, completed_at + FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `); + const insert = opened.query(` + INSERT OR IGNORE INTO chat_message_node ( + id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, + completion_json, metadata_json, depth, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const update = opened.query("UPDATE chat_conversation SET head_message_id = ?, head_version = head_version + 1 WHERE id = ?"); + for (const conversation of conversations) { + const path: MessageRow[] = []; + let cursor: string | null = conversation.head_message_id; + while (cursor) { + const row = read.get(cursor, conversation.owner_issuer, conversation.owner_sub) as MessageRow | undefined; + if (!row) throw new Error(`Legacy object ${cursor} is unavailable`); + path.push(row); + cursor = row.parent_message_id; + } + path.reverse(); + const namespace = createHash("sha256").update(`${conversation.owner_issuer}\0${conversation.owner_sub}`).digest("hex").slice(0, 32); + let parentMessageId: string | null = null; + for (const row of path) { + const parsed = parsedMessage(row); + const {id: _legacyId, ...legacyContent} = parsed; + const content: Omit = {...legacyContent, parentMessageId}; + const id: string = `sha256:${createHash("sha256").update(`${namespace}\0${canonicalMessage(content)}`).digest("hex")}`; + insert.run( + id, + conversation.owner_issuer, + conversation.owner_sub, + parentMessageId, + parsed.role, + JSON.stringify(parsed.parts), + JSON.stringify(parsed.origin), + JSON.stringify(parsed.completion), + JSON.stringify(parsed.metadata || {}), + row.depth, + parsed.createdAt, + parsed.completedAt + ); + parentMessageId = id; + } + update.run(parentMessageId, conversation.id); + } + opened.run("COMMIT"); + } catch (error) { + opened.run("ROLLBACK"); + throw error; + } +} + function getDatabase() { if (database) return database; mkdirSync(path.dirname(databasePath), {recursive: true}); @@ -41,189 +205,359 @@ function getDatabase() { 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")) { + const conversationColumns = columns(opened, "chat_conversation"); + if (!conversationColumns.some((column) => column.name === "settings_json")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'"); } + if (!conversationColumns.some((column) => column.name === "name")) { + opened.run("ALTER TABLE chat_conversation ADD COLUMN name TEXT"); + opened.run("UPDATE chat_conversation SET name = title WHERE name IS NULL"); + } + if (!conversationColumns.some((column) => column.name === "head_message_id")) { + opened.run("ALTER TABLE chat_conversation ADD COLUMN head_message_id TEXT"); + } + if (!conversationColumns.some((column) => column.name === "head_version")) { + opened.run("ALTER TABLE chat_conversation ADD COLUMN head_version INTEGER NOT NULL DEFAULT 0"); + } + if (!conversationColumns.some((column) => column.name === "metadata_version")) { + opened.run("ALTER TABLE chat_conversation ADD COLUMN metadata_version INTEGER NOT NULL DEFAULT 0"); + } + opened.run(` + CREATE TABLE IF NOT EXISTS chat_message_node ( + id TEXT PRIMARY KEY, + owner_issuer TEXT NOT NULL, + owner_sub TEXT NOT NULL, + parent_message_id TEXT REFERENCES chat_message_node(id), + role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), + parts_json TEXT NOT NULL, + origin_json TEXT NOT NULL, + completion_json TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + depth INTEGER NOT NULL, + created_at TEXT NOT NULL, + completed_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS chat_message_node_owner_parent + ON chat_message_node (owner_issuer, owner_sub, parent_message_id); + CREATE INDEX IF NOT EXISTS chat_conversation_owner_name + ON chat_conversation (owner_issuer, owner_sub, name); + `); + migrateLegacyMessages(opened); + migrateConversationHeadsToContentObjects(opened); database = opened; return opened; } -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 conversationName(value: unknown) { + if (typeof value !== "string") throw new Error("name must be a string"); + return value.trim().slice(0, 300); +} + +function nullableId(value: unknown, field: string) { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is invalid`); + return value.trim().slice(0, 160); +} + function conversationSummary(row: ConversationRow): ConversationSummary { return { id: row.id, - title: row.title, + name: row.name, + headMessageId: row.head_message_id, providerId: row.provider_id, model: row.model, messageCount: Number(row.message_count), createdAt: row.created_at, - updatedAt: row.updated_at + updatedAt: row.updated_at, + upstreamHeadMessageId: row.head_message_id, + headVersion: Number(row.head_version || 0), + metadataVersion: Number(row.metadata_version || 0) }; } -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; - 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) : "新对话"; -} +const conversationSelect = ` + SELECT c.id, COALESCE(c.name, c.title) AS name, c.head_message_id, c.provider_id, c.model, + c.head_version, c.metadata_version, + c.settings_json, c.created_at, c.updated_at, COALESCE(h.depth + 1, 0) AS message_count + FROM chat_conversation c + LEFT JOIN chat_message_node h ON h.id = c.head_message_id +`; function ownedConversation(identity: ChatIdentity, id: string) { - return getDatabase().query(` - 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 + return getDatabase().query(`${conversationSelect} WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ? `).get(id, identity.issuer, identity.sub) as ConversationRow | undefined; } +function ownedMessage(identity: ChatIdentity, id: string) { + return getDatabase().query(` + SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, + depth, created_at, completed_at + FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).get(id, identity.issuer, identity.sub) as MessageRow | undefined; +} + +function parsedMessage(row: MessageRow): StoredChatMessage { + const metadata = JSON.parse(row.metadata_json || "{}") as StoredChatMessage["metadata"]; + return { + id: row.id, + parentMessageId: row.parent_message_id, + role: row.role, + parts: JSON.parse(row.parts_json), + origin: JSON.parse(row.origin_json), + completion: JSON.parse(row.completion_json), + createdAt: row.created_at, + completedAt: row.completed_at, + ...(metadata && Object.keys(metadata).length ? {metadata} : {}) + }; +} + +function messagePath(identity: ChatIdentity, headId: string | null) { + const reversed: StoredChatMessage[] = []; + const seen = new Set(); + let id = headId; + while (id) { + if (seen.has(id) || reversed.length >= 500) throw new Error("Message history is cyclic or too long"); + seen.add(id); + const row = ownedMessage(identity, id); + if (!row) throw new Error("Conversation points to an unavailable message"); + reversed.push(parsedMessage(row)); + id = row.parent_message_id; + } + return reversed.reverse(); +} + +function normalizedParts(value: unknown) { + if (!Array.isArray(value)) throw new Error("parts is required"); + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded) > 1024 * 1024) throw new Error("message is too large"); + return JSON.parse(encoded) as StoredChatMessage["parts"]; +} + +function normalizedOrigin(value: unknown, role: StoredChatMessage["role"]): MessageOrigin { + if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as {type?: unknown}).type === "string") { + return JSON.parse(JSON.stringify(value)) as MessageOrigin; + } + if (role === "user") return {type: "user"}; + if (role === "system") return {type: "system", source: "chat"}; + return {type: "legacy"}; +} + +function normalizedCompletion(value: unknown): MessageCompletion { + if (value && typeof value === "object" && !Array.isArray(value) && (value as {status?: unknown}).status === "partial") { + return JSON.parse(JSON.stringify(value)) as MessageCompletion; + } + return {status: "complete"}; +} + +function normalizedMetadata(value: unknown) { + const metadata = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + const encoded = JSON.stringify(metadata); + if (Buffer.byteLength(encoded) > 64 * 1024) throw new Error("message metadata is too large"); + return encoded; +} + export function listConversations(identity: ChatIdentity): ConversationSummary[] { - return (getDatabase().query(` - 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 + return (getDatabase().query(`${conversationSelect} WHERE c.owner_issuer = ? AND c.owner_sub = ? - ORDER BY c.updated_at DESC - LIMIT 100 + 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 { +export function createConversation(identity: ChatIdentity, input: { + providerId: unknown; + model: unknown; + generationSettings?: unknown; + name?: unknown; + headMessageId?: unknown; +}): Conversation { const providerId = requiredString(input.providerId, "providerId", 80); const model = requiredString(input.model, "model", 300); + const name = input.name === undefined ? "" : conversationName(input.name); + const headMessageId = nullableId(input.headMessageId, "headMessageId"); + if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("headMessageId is unavailable"); const generationSettings = normalizeGenerationSettings(input.generationSettings); const timestamp = now(); const id = randomUUID(); - getDatabase().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: []}; + getDatabase().query(` + INSERT INTO chat_conversation ( + id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model, + settings_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp); + return getConversation(identity, id)!; } export function getConversation(identity: ChatIdentity, id: string): Conversation | null { const row = ownedConversation(identity, id); if (!row) return null; - 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}; + return { + ...conversationSummary(row), + generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), + messages: messagePath(identity, row.head_message_id) + }; } -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; +export function updateConversation(identity: ChatIdentity, id: string, input: { + providerId?: unknown; + model?: unknown; + generationSettings?: unknown; + name?: unknown; }) { const existing = ownedConversation(identity, id); if (!existing) return null; - const providerId = requiredString(input.providerId, "providerId", 80); - const model = requiredString(input.model, "model", 300); - const messages = normalizeMessages(input.messages); + const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80); + const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300); + const name = input.name === undefined ? existing.name : conversationName(input.name); + const generationSettings = input.generationSettings === undefined + ? normalizeGenerationSettings(JSON.parse(existing.settings_json || "{}")) + : normalizeGenerationSettings(input.generationSettings); + getDatabase().query(` + UPDATE chat_conversation SET title = ?, name = ?, provider_id = ?, model = ?, settings_json = ?, metadata_version = metadata_version + 1, updated_at = ? + WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).run(name, name, providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub); + return getConversation(identity, id); +} + +export function appendConversationMessage(identity: ChatIdentity, conversationId: string, input: { + id?: unknown; + expectedHeadId?: unknown; + parentMessageId?: unknown; + role?: unknown; + parts?: unknown; + origin?: unknown; + completion?: unknown; + metadata?: unknown; + providerId?: unknown; + model?: unknown; +}) { + const role = String(input.role || "") as StoredChatMessage["role"]; + if (!["system", "user", "assistant"].includes(role)) throw new Error("role is invalid"); + const id = input.id === undefined ? randomUUID() : requiredString(input.id, "id", 160); + const expectedHeadId = nullableId(input.expectedHeadId, "expectedHeadId"); + const parentMessageId = nullableId(input.parentMessageId, "parentMessageId"); + const parts = normalizedParts(input.parts); + const origin = normalizedOrigin(input.origin, role); + const completion = normalizedCompletion(input.completion); + const metadataJson = normalizedMetadata(input.metadata); const timestamp = now(); - 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); - }); + const existing = ownedConversation(identity, conversationId); + if (!existing) { + getDatabase().run("ROLLBACK"); + return {status: "missing" as const}; + } + if (existing.head_message_id !== expectedHeadId) { + getDatabase().run("ROLLBACK"); + return {status: "conflict" as const, conversation: getConversation(identity, conversationId)!}; + } + const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined; + if (parentMessageId && !parent) throw new Error("parentMessageId is unavailable"); + const depth = parent ? parent.depth + 1 : 0; + const already = ownedMessage(identity, id); + if (already) { + const same = already.parent_message_id === parentMessageId + && already.role === role + && already.parts_json === JSON.stringify(parts) + && already.origin_json === JSON.stringify(origin) + && already.completion_json === JSON.stringify(completion) + && already.metadata_json === metadataJson; + if (!same) throw new Error("message id already exists with different content"); + } else { + getDatabase().query(` + INSERT INTO chat_message_node ( + id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, + completion_json, metadata_json, depth, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + identity.issuer, + identity.sub, + parentMessageId, + role, + JSON.stringify(parts), + JSON.stringify(origin), + JSON.stringify(completion), + metadataJson, + depth, + timestamp, + timestamp + ); + } + const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80); + const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300); + const name = existing.name; getDatabase().query(` - UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ? + UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, head_version = head_version + 1, updated_at = ? WHERE id = ? AND owner_issuer = ? AND owner_sub = ? - `).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub); + `).run(name, name, id, providerId, model, timestamp, conversationId, identity.issuer, identity.sub); getDatabase().run("COMMIT"); + return {status: "ok" as const, conversation: getConversation(identity, conversationId)!}; } catch (error) { - getDatabase().run("ROLLBACK"); + try { getDatabase().run("ROLLBACK"); } catch {} throw error; } +} + +function normalizeLegacyMessages(value: unknown): StoredChatMessage[] { + if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries"); + const timestamp = now(); + let parentMessageId: string | null = null; + return value.map((message, index) => { + if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`); + const record = message as Record; + const role = String(record.role) as StoredChatMessage["role"]; + if (!["system", "user", "assistant"].includes(role)) throw new Error(`messages[${index}].role is invalid`); + const normalized: StoredChatMessage = { + id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(), + parentMessageId, + role, + parts: normalizedParts(record.parts), + origin: normalizedOrigin(record.origin, role), + completion: normalizedCompletion(record.completion), + createdAt: typeof record.createdAt === "string" ? record.createdAt : timestamp, + completedAt: typeof record.completedAt === "string" ? record.completedAt : timestamp, + ...(record.metadata ? {metadata: JSON.parse(normalizedMetadata(record.metadata))} : {}) + }; + parentMessageId = normalized.id; + return normalized; + }); +} + +// Compatibility for pending writes created by the previous client. New code commits one immutable message at a time. +export function saveConversationMessages(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; messages: unknown}) { + const existing = ownedConversation(identity, id); + if (!existing) return null; + const messages = normalizeLegacyMessages(input.messages); + let expectedHeadId = existing.head_message_id; + for (const message of messages) { + const already = ownedMessage(identity, message.id); + if (already) { + expectedHeadId = message.id; + continue; + } + const result = appendConversationMessage(identity, id, { + ...message, + expectedHeadId, + providerId: input.providerId, + model: input.model + }); + if (result.status !== "ok") throw new Error("Unable to import legacy conversation path"); + expectedHeadId = message.id; + } + if (!messages.length && existing.head_message_id) { + getDatabase().query(` + UPDATE chat_conversation SET head_message_id = NULL, provider_id = ?, model = ?, updated_at = ? + WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).run(requiredString(input.providerId, "providerId", 80), requiredString(input.model, "model", 300), now(), id, identity.issuer, identity.sub); + } return getConversation(identity, id); } @@ -233,3 +567,139 @@ export function deleteConversation(identity: ChatIdentity, id: string) { `).run(id, identity.issuer, identity.sub); return result.changes > 0; } + +function conversationRef(row: ConversationRow): ConversationRefState { + return { + id: row.id, + name: row.name, + headMessageId: row.head_message_id, + providerId: row.provider_id, + model: row.model, + generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), + headVersion: Number(row.head_version || 0), + metadataVersion: Number(row.metadata_version || 0), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function fetchRepository(identity: ChatIdentity, haveObjectIds: unknown): RepositoryFetch { + const have = new Set(Array.isArray(haveObjectIds) + ? haveObjectIds.filter((value): value is string => typeof value === "string").slice(0, 100_000) + : []); + const refs = (getDatabase().query(`${conversationSelect} + WHERE c.owner_issuer = ? AND c.owner_sub = ? ORDER BY c.updated_at DESC + `).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationRef); + const objects = (getDatabase().query(` + SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, + depth, created_at, completed_at + FROM chat_message_node + WHERE owner_issuer = ? AND owner_sub = ? + ORDER BY depth, created_at, id + `).all(identity.issuer, identity.sub) as MessageRow[]) + .filter((row) => !have.has(row.id)) + .map(parsedMessage); + return {refs, objects, fetchedAt: now()}; +} + +export function putRepositoryObjects(identity: ChatIdentity, objects: unknown) { + if (!Array.isArray(objects) || objects.length > 1000) throw new Error("objects must contain at most 1000 entries"); + let inserted = 0; + getDatabase().run("BEGIN IMMEDIATE"); + try { + const insert = getDatabase().query(` + INSERT INTO chat_message_node ( + id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, + completion_json, metadata_json, depth, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const value of objects) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("repository object is invalid"); + const object = value as StoredChatMessage; + if (typeof object.id !== "string" || !object.id.startsWith("sha256:")) throw new Error("repository object id is invalid"); + if (ownedMessage(identity, object.id)) continue; + const role = object.role; + if (!["system", "user", "assistant"].includes(role)) throw new Error("repository object role is invalid"); + const parentMessageId = nullableId(object.parentMessageId, "parentMessageId"); + const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined; + if (parentMessageId && !parent) throw new Error(`parent object ${parentMessageId} is unavailable`); + insert.run( + object.id, + identity.issuer, + identity.sub, + parentMessageId, + role, + JSON.stringify(normalizedParts(object.parts)), + JSON.stringify(normalizedOrigin(object.origin, role)), + JSON.stringify(normalizedCompletion(object.completion)), + normalizedMetadata(object.metadata), + parent ? parent.depth + 1 : 0, + requiredString(object.createdAt, "createdAt", 80), + requiredString(object.completedAt, "completedAt", 80) + ); + inserted += 1; + } + getDatabase().run("COMMIT"); + } catch (error) { + try { getDatabase().run("ROLLBACK"); } catch {} + throw error; + } + return inserted; +} + +export function pushRepositoryRef(identity: ChatIdentity, update: RepositoryRefUpdate) { + const id = requiredString(update.conversationId, "conversationId", 160); + const expectedHeadMessageId = nullableId(update.expectedHeadMessageId, "expectedHeadMessageId"); + const headMessageId = nullableId(update.headMessageId, "headMessageId"); + const existing = ownedConversation(identity, id); + if (!existing) { + if (expectedHeadMessageId !== null || Number(update.expectedHeadVersion || 0) !== 0 || Number(update.expectedMetadataVersion || 0) !== 0) { + return {status: "conflict" as const, ref: null}; + } + if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable"); + const name = conversationName(update.name); + const providerId = requiredString(update.providerId, "providerId", 80); + const model = requiredString(update.model, "model", 300); + const timestamp = now(); + getDatabase().query(` + INSERT INTO chat_conversation ( + id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model, + settings_json, head_version, metadata_version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?) + `).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(normalizeGenerationSettings(update.generationSettings)), update.createdAt || timestamp, timestamp); + return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)}; + } + if (existing.head_message_id !== expectedHeadMessageId + || Number(existing.head_version || 0) !== Number(update.expectedHeadVersion || 0) + || Number(existing.metadata_version || 0) !== Number(update.expectedMetadataVersion || 0)) { + return {status: "conflict" as const, ref: conversationRef(existing)}; + } + if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable"); + const name = conversationName(update.name); + const providerId = requiredString(update.providerId, "providerId", 80); + const model = requiredString(update.model, "model", 300); + const headChanged = existing.head_message_id !== headMessageId; + const metadataChanged = existing.name !== name + || existing.provider_id !== providerId + || existing.model !== model + || existing.settings_json !== JSON.stringify(normalizeGenerationSettings(update.generationSettings)); + getDatabase().query(` + UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, settings_json = ?, + head_version = head_version + ?, metadata_version = metadata_version + ?, updated_at = ? + WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).run( + name, + name, + headMessageId, + providerId, + model, + JSON.stringify(normalizeGenerationSettings(update.generationSettings)), + headChanged ? 1 : 0, + metadataChanged ? 1 : 0, + now(), + id, + identity.issuer, + identity.sub + ); + return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)}; +} diff --git a/chat/lib/fullscreen-editor.test.mjs b/chat/lib/fullscreen-editor.test.mjs new file mode 100644 index 0000000..a64e43f --- /dev/null +++ b/chat/lib/fullscreen-editor.test.mjs @@ -0,0 +1,21 @@ +import {describe, expect, test} from "bun:test"; +import { + fullscreenEditorCharacterThreshold, + fullscreenEditorLineThreshold, + shouldOpenFullscreenEditor +} from "./fullscreen-editor.ts"; + +describe("fullscreen editor", () => { + test("keeps short messages in the compact composer", () => { + expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold - 1))).toBe(false); + expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold - 1).fill("line").join("\n"))).toBe(false); + }); + + test("opens for a long single-line message", () => { + expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold))).toBe(true); + }); + + test("opens for a message with many lines", () => { + expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold).fill("line").join("\n"))).toBe(true); + }); +}); diff --git a/chat/lib/fullscreen-editor.ts b/chat/lib/fullscreen-editor.ts new file mode 100644 index 0000000..cdecd9f --- /dev/null +++ b/chat/lib/fullscreen-editor.ts @@ -0,0 +1,8 @@ +export const fullscreenEditorCharacterThreshold = 600; +export const fullscreenEditorLineThreshold = 8; + +export function shouldOpenFullscreenEditor(value: unknown) { + const text = String(value ?? ""); + const lineCount = text ? text.split(/\r?\n/).length : 0; + return text.length >= fullscreenEditorCharacterThreshold || lineCount >= fullscreenEditorLineThreshold; +} diff --git a/chat/lib/import-title-template.test.mjs b/chat/lib/import-title-template.test.mjs new file mode 100644 index 0000000..8368089 --- /dev/null +++ b/chat/lib/import-title-template.test.mjs @@ -0,0 +1,31 @@ +import {describe, expect, test} from "bun:test"; +import {applyImportTitleTemplate, importFileStem, importSourceFolder} from "./import-title-template.ts"; + +const context = { + title: "Repair auth", + format: "codex", + file: "rollout-123", + folder: "13", + date: "2026-08-13", + model: "gpt-5.6-sol", + provider: "openai", + index: 7 +}; + +describe("import title templates", () => { + test("renders paths and import metadata", () => { + expect(applyImportTitleTemplate("{format}/{date}/{title}", context)).toBe("codex/2026-08-13/Repair auth"); + expect(applyImportTitleTemplate("{index} · {file} · {model}", context)).toBe("7 · rollout-123 · gpt-5.6-sol"); + }); + + test("defaults to the source title and rejects unknown variables", () => { + expect(applyImportTitleTemplate("", context)).toBe("Repair auth"); + expect(() => applyImportTitleTemplate("{project}/{title}", context)).toThrow("未知标题变量"); + }); + + test("extracts source file and folder labels", () => { + expect(importFileStem("backup.xiteng-chat.json")).toBe("backup"); + expect(importFileStem("rollout.jsonl")).toBe("rollout"); + expect(importSourceFolder("sessions.zip / nested / rollout.jsonl")).toBe("nested"); + }); +}); diff --git a/chat/lib/import-title-template.ts b/chat/lib/import-title-template.ts new file mode 100644 index 0000000..8b4d502 --- /dev/null +++ b/chat/lib/import-title-template.ts @@ -0,0 +1,32 @@ +export type ImportTitleContext = { + title: string; + format: string; + file: string; + folder: string; + date: string; + model: string; + provider: string; + index: number; +}; + +const placeholders = new Set(["title", "format", "file", "folder", "date", "model", "provider", "index"]); + +export function applyImportTitleTemplate(template: string, context: ImportTitleContext) { + const source = template.trim() || "{title}"; + const unknown = [...source.matchAll(/\{([^{}]+)\}/g)] + .map((match) => match[1]) + .filter((name) => !placeholders.has(name)); + if (unknown.length) throw new Error(`未知标题变量:${[...new Set(unknown)].map((name) => `{${name}}`).join("、")}`); + const values: Record = {...context, index: String(context.index)}; + const rendered = source.replace(/\{([^{}]+)\}/g, (match, name) => values[name] ?? match).trim(); + return rendered || context.title.trim() || "导入的会话"; +} + +export function importFileStem(filename: string) { + return filename.replace(/\.xiteng-chat\.json$/i, "").replace(/\.(?:jsonl|json)$/i, ""); +} + +export function importSourceFolder(source: string) { + const parts = source.replaceAll("\\", "/").split("/").map((part) => part.trim()).filter(Boolean); + return parts.length > 1 ? parts.at(-2)! : ""; +} diff --git a/chat/lib/incremental-markdown-cache.test.mjs b/chat/lib/incremental-markdown-cache.test.mjs new file mode 100644 index 0000000..001d4c0 --- /dev/null +++ b/chat/lib/incremental-markdown-cache.test.mjs @@ -0,0 +1,73 @@ +import {describe, expect, test} from "bun:test"; +import {IncrementalMarkdownCache} from "./incremental-markdown-cache.ts"; + +describe("incremental markdown cache", () => { + test("never parses a stable prefix again", () => { + const cache = new IncrementalMarkdownCache(); + const calls = []; + const render = (source, type, index, stable) => { + calls.push({source, type, index, stable}); + return {rendered: source.toUpperCase()}; + }; + + cache.render("message", "first", render); + const afterFirst = calls.length; + const closed = cache.render("message", "first\n\n", render); + const afterClosed = calls.length; + const growingTail = cache.render("message", "first\n\nsecond", render); + const longerTail = cache.render("message", "first\n\nsecond grows", render); + + expect(afterFirst).toBe(1); + expect(afterClosed).toBe(2); + expect(closed.blocks[0].stable).toBe(true); + expect(growingTail.reused).toBe(1); + expect(growingTail.parsed).toBe(1); + expect(longerTail.reused).toBe(1); + expect(longerTail.parsed).toBe(1); + expect(calls.filter((call) => call.source === "first")).toHaveLength(2); + }); + + test("assigns stable indexes as blocks become frozen", () => { + const cache = new IncrementalMarkdownCache(); + const render = () => ({}); + const first = cache.render("message", "one\n\ntwo", render); + const second = cache.render("message", "one\n\ntwo\n\nthree", render); + + expect(first.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, false]]); + expect(second.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, true], [2, false]]); + }); + + test("resets when the source is edited before the stable prefix", () => { + const cache = new IncrementalMarkdownCache(); + const render = (source) => ({rendered: source}); + cache.render("message", "one\n\ntwo", render); + const result = cache.render("message", "changed\n\ntwo", render); + expect(result.reused).toBe(0); + expect(result.blocks[0].source).toBe("changed"); + }); + + test("moves cached blocks when a working message receives its immutable id", () => { + const cache = new IncrementalMarkdownCache(); + const render = (source) => ({rendered: source}); + cache.render("working", "first\n\n", render); + + cache.move("working", "immutable"); + const result = cache.render("immutable", "first\n\nsecond", render); + + expect(cache.has("working")).toBe(false); + expect(cache.has("immutable")).toBe(true); + expect(result.reused).toBe(1); + expect(result.blocks.map((block) => block.index)).toEqual([0, 1]); + }); + + test("freezes the final open block when the stream completes", () => { + const cache = new IncrementalMarkdownCache(); + const render = (source) => ({rendered: source}); + cache.render("message", "last paragraph", render); + + const result = cache.render("message", "last paragraph", render, undefined, true); + + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0].stable).toBe(true); + }); +}); diff --git a/chat/lib/incremental-markdown-cache.ts b/chat/lib/incremental-markdown-cache.ts new file mode 100644 index 0000000..60af364 --- /dev/null +++ b/chat/lib/incremental-markdown-cache.ts @@ -0,0 +1,79 @@ +import {splitStreamingMarkdown} from "./streaming-markdown"; + +export type IncrementalMarkdownBlock = T & { + source: string; + type: string; + index: number; + stable: boolean; +}; + +export type IncrementalMarkdownResult = { + blocks: Array>; + parsed: number; + reused: number; +}; + +type IncrementalMarkdownState = { + stableSource: string; + stableBlocks: Array>; + nextIndex: number; +}; + +export class IncrementalMarkdownCache { + private readonly states = new Map>(); + + render( + messageId: string, + value: string, + renderBlock: (source: string, type: string, index: number, stable: boolean) => T, + onSplit?: (durationMs: number) => void, + complete = false + ): IncrementalMarkdownResult { + let state = this.states.get(messageId); + if (!state || !value.startsWith(state.stableSource)) { + state = {stableSource: "", stableBlocks: [], nextIndex: 0}; + this.states.set(messageId, state); + } + if (value === state.stableSource) return {blocks: state.stableBlocks, parsed: 0, reused: state.stableBlocks.length}; + + const tail = value.slice(state.stableSource.length); + const startedAt = performance.now(); + const split = splitStreamingMarkdown(tail, complete); + onSplit?.(performance.now() - startedAt); + const renderedTail = split.blocks.map((block, offset) => ({ + ...renderBlock(block.source, block.type, state!.nextIndex + offset, block.stable), + source: block.source, + type: block.type, + index: state!.nextIndex + offset, + stable: block.stable + })); + const newlyStable = renderedTail.filter((_block, index) => split.blocks[index].end <= split.stableOffset); + const previouslyStableCount = state.stableBlocks.length; + if (split.stableOffset > 0) { + state.stableSource += tail.slice(0, split.stableOffset); + state.stableBlocks.push(...newlyStable); + state.nextIndex += newlyStable.length; + } + return { + blocks: [...state.stableBlocks.slice(0, state.stableBlocks.length - newlyStable.length), ...renderedTail], + parsed: renderedTail.length, + reused: previouslyStableCount + }; + } + + delete(messageId: string) { + this.states.delete(messageId); + } + + move(fromMessageId: string, toMessageId: string) { + if (fromMessageId === toMessageId) return; + const state = this.states.get(fromMessageId); + if (!state) return; + this.states.set(toMessageId, state); + this.states.delete(fromMessageId); + } + + has(messageId: string) { + return this.states.has(messageId); + } +} diff --git a/chat/lib/math-markdown.test.mjs b/chat/lib/math-markdown.test.mjs new file mode 100644 index 0000000..b06fd9e --- /dev/null +++ b/chat/lib/math-markdown.test.mjs @@ -0,0 +1,39 @@ +import {describe, expect, test} from "bun:test"; +import {marked} from "marked"; +import {protectMath, restoreMath} from "./math-markdown.ts"; + +function parse(value) { + const {source, fragments} = protectMath(value); + return {html: restoreMath(marked.parse(source), fragments), fragments}; +} + +describe("math markdown boundary", () => { + test("preserves inline and display TeX delimiters through Markdown", () => { + const {html, fragments} = parse("Inline \\(x^2 + y^2\\) and $z^2$.\n\n$$\\int_0^1 x\\,dx$$\n\n\\[\\frac{a}{b}\\]"); + expect(fragments).toHaveLength(4); + expect(html).toContain("\\(x^2 + y^2\\)"); + expect(html).toContain("$z^2$"); + expect(html).toContain("$$\\int_0^1 x\\,dx$$"); + expect(html).toContain("\\[\\frac{a}{b}\\]"); + expect(html.match(/class="math-fragment"/g)).toHaveLength(4); + }); + + test("does not treat escaped dollars as math", () => { + const {fragments} = protectMath("Price: \\$5"); + expect(fragments).toHaveLength(0); + }); + + test("keeps TeX-looking code as code for MathJax skip tags", () => { + const {html} = parse("`$not_math$` and $math$"); + expect(html).toContain("$not_math$"); + expect(html).toContain("and $math$"); + expect(html.match(/class="math-fragment"/g)).toHaveLength(1); + }); + + test("escapes HTML embedded inside a math fragment", () => { + const {html} = parse("$x y$"); + expect(html).not.toContain(""']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[character]!); +} + +export function protectMath(value: string) { + const fragments: string[] = []; + const source = value.replace(mathPattern, (fragment) => { + const index = fragments.push(fragment) - 1; + return `\uE000xiteng-math-${index}\uE001`; + }); + return {source, fragments}; +} + +function mathFragmentKey(fragment: string, index: number) { + let hash = 2166136261; + for (let offset = 0; offset < fragment.length; offset += 1) { + hash ^= fragment.charCodeAt(offset); + hash = Math.imul(hash, 16777619); + } + return `${index}-${fragment.length}-${(hash >>> 0).toString(36)}`; +} + +export function restoreMath(value: string, fragments: string[]) { + return value.replace(tokenPattern, (_token, rawIndex: string, offset: number) => { + const index = Number(rawIndex); + const fragment = fragments[index] || ""; + const codeStart = value.lastIndexOf("", offset); + if (codeStart > codeEnd) return escapeHtml(fragment); + return `${escapeHtml(fragment)}`; + }); +} diff --git a/chat/lib/message-graph.test.mjs b/chat/lib/message-graph.test.mjs new file mode 100644 index 0000000..c02f6cf --- /dev/null +++ b/chat/lib/message-graph.test.mjs @@ -0,0 +1,35 @@ +import {describe, expect, test} from "bun:test"; +import {mergeMessageGraph, messageChildrenInGraph, messagePathInGraph, newestBranchTipInGraph, rootEditAlternativesInGraph} from "./message-graph.ts"; + +const node = (id, parentMessageId, createdAt) => ({ + id, parentMessageId, role: id.startsWith("a") ? "assistant" : "user", parts: [], + origin: {type: "legacy"}, completion: {status: "complete"}, createdAt, completedAt: createdAt +}); + +describe("message graph", () => { + const root = node("u1", null, "2026-01-01T00:00:00Z"); + const answerA = node("a1", "u1", "2026-01-01T00:00:01Z"); + const answerB = node("a2", "u1", "2026-01-01T00:00:02Z"); + const followupB = node("u2", "a2", "2026-01-01T00:00:03Z"); + const graph = mergeMessageGraph([root, answerA], [answerB, followupB]); + + test("sorts sibling branches deterministically", () => { + expect(messageChildrenInGraph(graph, "u1").map((message) => message.id)).toEqual(["a1", "a2"]); + }); + + test("builds the selected path without changing a ref", () => { + expect(messagePathInGraph(graph, "u2").map((message) => message.id)).toEqual(["u1", "a2", "u2"]); + }); + + test("uses the current ref for its branch and newest descendants for alternatives", () => { + expect(newestBranchTipInGraph(graph, "a1", new Set(["u1", "a1"]), "a1")).toBe("a1"); + expect(newestBranchTipInGraph(graph, "a2", new Set(["u1", "a1"]), "a1")).toBe("u2"); + }); + + test("does not mix unrelated root messages into first-message edit branches", () => { + const editedRoot = {...node("u3", null, "2026-01-01T00:00:04Z"), origin: {type: "user", sourceMessageId: "u1"}}; + const unrelated = node("u4", null, "2026-01-01T00:00:05Z"); + const roots = mergeMessageGraph([...graph.values()], [editedRoot, unrelated]); + expect(rootEditAlternativesInGraph(roots, "u3").map((message) => message.id)).toEqual(["u1", "u3"]); + }); +}); diff --git a/chat/lib/message-graph.ts b/chat/lib/message-graph.ts new file mode 100644 index 0000000..b492eec --- /dev/null +++ b/chat/lib/message-graph.ts @@ -0,0 +1,62 @@ +import type {StoredChatMessage} from "./conversation-types"; + +export function mergeMessageGraph(...groups: StoredChatMessage[][]) { + return new Map(groups.flat().map((message) => [message.id, message])); +} + +export function messagePathInGraph(messages: Map, headMessageId: string | null) { + const reversed: StoredChatMessage[] = []; + const seen = new Set(); + let cursor = headMessageId; + while (cursor) { + if (seen.has(cursor) || reversed.length >= 500) return []; + seen.add(cursor); + const message = messages.get(cursor); + if (!message) return []; + reversed.push(message); + cursor = message.parentMessageId; + } + return reversed.reverse(); +} + +export function messageChildrenInGraph(messages: Map, parentMessageId: string | null) { + return [...messages.values()] + .filter((message) => message.parentMessageId === parentMessageId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); +} + +export function rootEditAlternativesInGraph(messages: Map, messageId: string) { + const roots = [...messages.values()].filter((message) => message.parentMessageId === null); + const related = new Set([messageId]); + let changed = true; + while (changed) { + changed = false; + for (const message of roots) { + const sourceMessageId = "sourceMessageId" in message.origin ? message.origin.sourceMessageId : undefined; + if (!related.has(message.id) && (!sourceMessageId || !related.has(sourceMessageId))) continue; + if (!related.has(message.id)) { related.add(message.id); changed = true; } + if (sourceMessageId && !related.has(sourceMessageId)) { related.add(sourceMessageId); changed = true; } + } + } + return roots + .filter((message) => related.has(message.id)) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); +} + +export function newestBranchTipInGraph( + messages: Map, + startId: string, + currentPathIds: Set, + currentHeadMessageId: string | null +) { + if (currentPathIds.has(startId)) return currentHeadMessageId || startId; + let cursor = startId; + const seen = new Set(); + while (!seen.has(cursor)) { + seen.add(cursor); + const children = messageChildrenInGraph(messages, cursor); + if (!children.length) return cursor; + cursor = children.at(-1)!.id; + } + return startId; +} diff --git a/chat/lib/message-object.ts b/chat/lib/message-object.ts new file mode 100644 index 0000000..e772485 --- /dev/null +++ b/chat/lib/message-object.ts @@ -0,0 +1,29 @@ +import type {StoredChatMessage} from "./conversation-types"; + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalValue(entry)])); +} + +export function canonicalMessage(message: Omit) { + return JSON.stringify(canonicalValue(message)); +} + +export async function messageObjectId(message: Omit, namespace = "") { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${namespace}\0${canonicalMessage(message)}`)); + return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +export async function createMessageObject(message: Omit, namespace = ""): Promise { + return {...message, id: await messageObjectId(message, namespace)}; +} + +export async function validMessageObjectId(message: StoredChatMessage, namespace = "") { + if (!message.id.startsWith("sha256:")) return false; + const {id: _id, ...content} = message; + return message.id === await messageObjectId(content, namespace); +} diff --git a/chat/lib/model-display.test.mjs b/chat/lib/model-display.test.mjs new file mode 100644 index 0000000..303f56b --- /dev/null +++ b/chat/lib/model-display.test.mjs @@ -0,0 +1,14 @@ +import {describe, expect, test} from "bun:test"; +import {compactModelName} from "./model-display.ts"; + +describe("compact model names", () => { + test("hides an Ollama model tag in compact UI", () => { + expect(compactModelName("gemma4:e4b-it-qat")).toBe("gemma4"); + expect(compactModelName("qwen3.5:9b-q4_K_M")).toBe("qwen3.5"); + }); + + test("keeps ordinary model identifiers unchanged", () => { + expect(compactModelName("claude-sonnet-4-6")).toBe("claude-sonnet-4-6"); + expect(compactModelName("Qwen3.5-9B-Q4_K_M.gguf")).toBe("Qwen3.5-9B-Q4_K_M.gguf"); + }); +}); diff --git a/chat/lib/model-display.ts b/chat/lib/model-display.ts new file mode 100644 index 0000000..cbc9272 --- /dev/null +++ b/chat/lib/model-display.ts @@ -0,0 +1,5 @@ +export function compactModelName(value: unknown) { + const name = String(value ?? "").trim(); + const tagIndex = name.indexOf(":"); + return tagIndex > 0 ? name.slice(0, tagIndex) : name; +} diff --git a/chat/lib/offline-history.ts b/chat/lib/offline-history.ts index fca9b35..139d20a 100644 --- a/chat/lib/offline-history.ts +++ b/chat/lib/offline-history.ts @@ -1,7 +1,7 @@ -import type {Conversation, ConversationSummary} from "./conversation-types"; +import type {Conversation, ConversationRefState, ConversationSummary, RepositoryFetch, RepositoryRefUpdate, StoredChatMessage, WorkingItem} from "./conversation-types"; const databaseName = "xiteng-chat-offline"; -const databaseVersion = 1; +const databaseVersion = 3; const activeProfileKey = "xiteng-chat-offline-profile"; type CachedProfile = { @@ -9,15 +9,46 @@ type CachedProfile = { config: T; summaries: ConversationSummary[]; updatedAt: string; + lastFetchAt?: string; }; -type CachedConversation = Conversation & {cacheKey: string; profileId: string}; +type CachedConversationRef = Omit & { + cacheKey: string; + profileId: string; + messages?: StoredChatMessage[]; +}; + +type CachedMessage = StoredChatMessage & {cacheKey: string; profileId: string}; +type CachedWorkingItem = WorkingItem & {cacheKey: string; profileId: string}; + +type CachedReflog = { + cacheKey: string; + profileId: string; + conversationId: string; + oldHeadMessageId: string | null; + newHeadMessageId: string | null; + reason: "commit" | "create" | "fetch" | "reset" | "rename" | "delete"; + createdAt: string; +}; + +export type RepositoryOutboxRecord = { + cacheKey: string; + profileId: string; + conversationId: string; + objectIds: string[]; + expectedHeadMessageId: string | null; + expectedHeadVersion: number; + expectedMetadataVersion: number; + createdAt: string; + updatedAt: string; +}; export type PendingConversationChange = { cacheKey: string; profileId: string; conversationId: string; - method: "PUT" | "PATCH" | "DELETE"; + requestPath?: string; + method: "POST" | "PUT" | "PATCH" | "DELETE"; body?: string; createdAt: string; }; @@ -36,6 +67,23 @@ function openDatabase() { const pending = database.createObjectStore("pending", {keyPath: "cacheKey"}); pending.createIndex("profileId", "profileId"); } + if (!database.objectStoreNames.contains("messages")) { + const messages = database.createObjectStore("messages", {keyPath: "cacheKey"}); + messages.createIndex("profileId", "profileId"); + } + if (!database.objectStoreNames.contains("working")) { + const working = database.createObjectStore("working", {keyPath: "cacheKey"}); + working.createIndex("profileId", "profileId"); + working.createIndex("profileConversation", ["profileId", "conversationId"]); + } + if (!database.objectStoreNames.contains("reflog")) { + const reflog = database.createObjectStore("reflog", {keyPath: "cacheKey"}); + reflog.createIndex("profileConversation", ["profileId", "conversationId"]); + } + if (!database.objectStoreNames.contains("repositoryOutbox")) { + const outbox = database.createObjectStore("repositoryOutbox", {keyPath: "cacheKey"}); + outbox.createIndex("profileId", "profileId"); + } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error("Unable to open offline history")); @@ -58,14 +106,110 @@ function activeProfileId() { return window.localStorage.getItem(activeProfileKey) || ""; } -function conversationCacheKey(profileId: string, conversationId: string) { - return `${profileId}:${conversationId}`; +function profileCacheKey(profileId: string, id: string) { + return `${profileId}:${id}`; +} + +function normalizedCachedMessage(message: Partial, parentMessageId: string | null, timestamp: string): StoredChatMessage { + const role = message.role || "user"; + return { + id: message.id || crypto.randomUUID(), + parentMessageId: message.parentMessageId === undefined ? parentMessageId : message.parentMessageId, + role, + parts: Array.isArray(message.parts) ? message.parts : [], + origin: message.origin || (role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "legacy-cache"} : {type: "legacy"}), + completion: message.completion || {status: "complete"}, + createdAt: message.createdAt || timestamp, + completedAt: message.completedAt || timestamp, + ...(message.metadata ? {metadata: message.metadata} : {}) + }; +} + +function normalizedConversationSummary(summary: Partial & {title?: unknown}): ConversationSummary | null { + if (typeof summary.id !== "string" || !summary.id) return null; + const timestamp = new Date().toISOString(); + return { + id: summary.id, + name: typeof summary.name === "string" ? summary.name : typeof summary.title === "string" ? summary.title : "", + headMessageId: typeof summary.headMessageId === "string" ? summary.headMessageId : null, + providerId: typeof summary.providerId === "string" ? summary.providerId : "", + model: typeof summary.model === "string" ? summary.model : "", + messageCount: typeof summary.messageCount === "number" && Number.isFinite(summary.messageCount) ? summary.messageCount : 0, + createdAt: typeof summary.createdAt === "string" ? summary.createdAt : timestamp, + updatedAt: typeof summary.updatedAt === "string" ? summary.updatedAt : typeof summary.createdAt === "string" ? summary.createdAt : timestamp, + ...(summary.upstreamHeadMessageId === null || typeof summary.upstreamHeadMessageId === "string" ? {upstreamHeadMessageId: summary.upstreamHeadMessageId} : {}), + ...(typeof summary.headVersion === "number" ? {headVersion: summary.headVersion} : {}), + ...(typeof summary.metadataVersion === "number" ? {metadataVersion: summary.metadataVersion} : {}) + }; } export function activateOfflineProfile(profileId: string) { window.localStorage.setItem(activeProfileKey, profileId); } +export function activeOfflineProfileId() { + return activeProfileId(); +} + +export async function mergeOfflineProfiles(sourceProfileId: string, targetProfileId: string) { + if (!sourceProfileId || sourceProfileId === targetProfileId) return; + const database = await openDatabase(); + await new Promise((resolve, reject) => { + const storeNames = ["profiles", "conversations", "pending", "messages", "working", "reflog", "repositoryOutbox"]; + const current = database.transaction(storeNames, "readwrite"); + const profiles = current.objectStore("profiles"); + const sourceProfileRequest = profiles.get(sourceProfileId); + const targetProfileRequest = profiles.get(targetProfileId); + let sourceProfile: CachedProfile | undefined; + let targetProfile: CachedProfile | undefined; + const profileReady = () => { + if (sourceProfileRequest.readyState !== "done" || targetProfileRequest.readyState !== "done") return; + sourceProfile = sourceProfileRequest.result as CachedProfile | undefined; + targetProfile = targetProfileRequest.result as CachedProfile | undefined; + if (!sourceProfile) return; + const summaries = new Map(); + for (const summary of [...(sourceProfile.summaries || []), ...(targetProfile?.summaries || [])]) { + const existing = summaries.get(summary.id); + if (!existing || summary.updatedAt > existing.updatedAt) summaries.set(summary.id, summary); + } + profiles.put({ + ...(sourceProfile || {}), + ...(targetProfile || {}), + id: targetProfileId, + summaries: [...summaries.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + updatedAt: new Date().toISOString(), + lastFetchAt: targetProfile?.lastFetchAt || sourceProfile.lastFetchAt + } satisfies CachedProfile); + }; + sourceProfileRequest.onsuccess = profileReady; + targetProfileRequest.onsuccess = profileReady; + + for (const storeName of storeNames.slice(1)) { + const store = current.objectStore(storeName); + const indexName = storeName === "reflog" ? "profileConversation" : "profileId"; + const range = storeName === "reflog" + ? IDBKeyRange.bound([sourceProfileId, ""], [sourceProfileId, "\uffff"]) + : IDBKeyRange.only(sourceProfileId); + const request = store.index(indexName).getAll(range); + request.onsuccess = () => { + for (const raw of request.result as Array>) { + const oldKey = String(raw.cacheKey || ""); + const suffix = oldKey.startsWith(`${sourceProfileId}:`) ? oldKey.slice(sourceProfileId.length) : `:${crypto.randomUUID()}`; + const migrated = {...raw, profileId: targetProfileId, cacheKey: `${targetProfileId}${suffix}`}; + const existingRequest = store.get(migrated.cacheKey as IDBValidKey); + existingRequest.onsuccess = () => { + const existing = existingRequest.result as Record | undefined; + if (!existing || String(raw.updatedAt || raw.createdAt || "") > String(existing.updatedAt || existing.createdAt || "")) store.put(migrated); + }; + } + }; + } + current.oncomplete = () => { database.close(); resolve(); }; + current.onerror = () => { database.close(); reject(current.error || new Error("Unable to merge local repositories")); }; + current.onabort = () => { database.close(); reject(current.error || new Error("Local repository merge was aborted")); }; + }); +} + export async function cacheChatConfig(profileId: string, config: T) { activateOfflineProfile(profileId); const current = await transaction | undefined>("profiles", "readonly", (store) => store.get(profileId)); @@ -73,16 +217,32 @@ export async function cacheChatConfig(profileId: string, config: T) { id: profileId, config, summaries: current?.summaries || [], - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), + lastFetchAt: current?.lastFetchAt }; await transaction("profiles", "readwrite", (store) => store.put(profile)); } -export async function loadCachedChatConfig() { - const profileId = activeProfileId(); +export async function loadCachedChatConfig(requestedProfileId?: string) { + const profileId = requestedProfileId || activeProfileId(); if (!profileId) return null; const profile = await transaction | undefined>("profiles", "readonly", (store) => store.get(profileId)); - return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt} : null; + return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt, lastFetchAt: profile.lastFetchAt || ""} : null; +} + +export async function cachedLastFetchAt() { + const profileId = activeProfileId(); + if (!profileId) return ""; + const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); + return profile?.lastFetchAt || ""; +} + +export async function recordRepositoryFetch(timestamp: string) { + const profileId = activeProfileId(); + if (!profileId) return; + const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); + if (!profile) return; + await transaction("profiles", "readwrite", (store) => store.put({...profile, lastFetchAt: timestamp})); } export async function cacheConversationSummaries(summaries: ConversationSummary[]) { @@ -90,57 +250,487 @@ export async function cacheConversationSummaries(summaries: ConversationSummary[ if (!profileId) return; const current = await transaction("profiles", "readonly", (store) => store.get(profileId)); if (!current) return; - await transaction("profiles", "readwrite", (store) => store.put({ - ...current, - summaries, - updatedAt: new Date().toISOString() - })); + const normalized = summaries.map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary)); + await transaction("profiles", "readwrite", (store) => store.put({...current, summaries: normalized, updatedAt: new Date().toISOString()})); } export async function loadCachedConversationSummaries() { const profileId = activeProfileId(); if (!profileId) return []; const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); - return profile?.summaries || []; + return (profile?.summaries || []).map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary)); } export async function cacheConversation(conversation: Conversation) { const profileId = activeProfileId(); if (!profileId) return; - const record: CachedConversation = { - ...conversation, - cacheKey: conversationCacheKey(profileId, conversation.id), + let parentMessageId: string | null = null; + for (const candidate of conversation.messages) { + const message = normalizedCachedMessage(candidate, parentMessageId, conversation.updatedAt); + const record: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId}; + await transaction("messages", "readwrite", (store) => store.put(record)); + parentMessageId = message.id; + } + const {messages: _messages, ...summary} = conversation; + const ref: CachedConversationRef = { + ...summary, + upstreamHeadMessageId: conversation.upstreamHeadMessageId === undefined ? conversation.headMessageId : conversation.upstreamHeadMessageId, + headVersion: conversation.headVersion || 0, + metadataVersion: conversation.metadataVersion || 0, + cacheKey: profileCacheKey(profileId, conversation.id), profileId }; - await transaction("conversations", "readwrite", (store) => store.put(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))); + await transaction("conversations", "readwrite", (store) => store.put(ref)); + await cacheConversationSummaries([ + summary, + ...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id) + ].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); +} + +async function loadCachedMessage(profileId: string, id: string) { + const record = await transaction("messages", "readonly", (store) => store.get(profileCacheKey(profileId, id))); + if (!record) return null; + const {cacheKey: _cacheKey, profileId: _profileId, ...message} = record; + return message; } export async function loadCachedConversation(id: string) { const profileId = activeProfileId(); if (!profileId) return null; - const record = await transaction("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id))); + const record = await transaction("conversations", "readonly", (store) => store.get(profileCacheKey(profileId, id))); if (!record) return null; - const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record; - return conversation; + const {cacheKey: _cacheKey, profileId: _profileId, messages: legacyMessages, ...conversation} = record; + const normalizedSummary = normalizedConversationSummary(conversation as Partial & {title?: unknown}); + if (!normalizedSummary) return null; + if (legacyMessages) { + const normalized: Conversation = { + ...conversation, + ...normalizedSummary, + headMessageId: normalizedSummary.headMessageId || legacyMessages.at(-1)?.id || null, + messages: legacyMessages.map((message, index) => normalizedCachedMessage(message, index ? legacyMessages[index - 1].id : null, conversation.updatedAt)) + }; + await cacheConversation(normalized); + return normalized; + } + const reversed: StoredChatMessage[] = []; + const seen = new Set(); + let messageId = conversation.headMessageId; + while (messageId) { + if (seen.has(messageId) || reversed.length >= 500) return null; + seen.add(messageId); + const message = await loadCachedMessage(profileId, messageId); + if (!message) return null; + reversed.push(message); + messageId = message.parentMessageId; + } + return {...conversation, ...normalizedSummary, messages: reversed.reverse()}; } export async function removeCachedConversation(id: string) { const profileId = activeProfileId(); if (!profileId) return; - await transaction("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id))); - const summaries = await loadCachedConversationSummaries(); - await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id)); + await transaction("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id))); + await transaction("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id))); + await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((conversation) => conversation.id !== id)); +} + +export async function deleteLocalConversation(id: string) { + const profileId = activeProfileId(); + if (!profileId) return; + const conversation = await loadCachedConversation(id); + await transaction("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id))); + await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((item) => item.id !== id)); + if (conversation?.upstreamHeadMessageId !== undefined && ((conversation.headVersion || 0) > 0 || (conversation.metadataVersion || 0) > 0)) { + await queueConversationChange({conversationId: id, method: "DELETE"}); + } + await transaction("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id))); +} + +export async function saveWorkingItem(item: WorkingItem) { + const profileId = activeProfileId(); + if (!profileId) return item; + const record: CachedWorkingItem = {...item, cacheKey: profileCacheKey(profileId, item.id), profileId}; + await transaction("working", "readwrite", (store) => store.put(record)); + return item; +} + +export async function listWorkingItems(conversationId?: string) { + const profileId = activeProfileId(); + if (!profileId) return []; + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const current = database.transaction("working", "readonly"); + const store = current.objectStore("working"); + const request = conversationId + ? store.index("profileConversation").getAll([profileId, conversationId]) + : store.index("profileId").getAll(profileId); + request.onsuccess = () => resolve((request.result as CachedWorkingItem[]) + .map(({cacheKey: _cacheKey, profileId: _profileId, ...item}) => item) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); + request.onerror = () => reject(request.error || new Error("Unable to read working items")); + current.oncomplete = () => database.close(); + }); +} + +export async function removeWorkingItem(id: string) { + const profileId = activeProfileId(); + if (!profileId) return; + await transaction("working", "readwrite", (store) => store.delete(profileCacheKey(profileId, id))); +} + +export async function listCachedObjectIds() { + const profileId = activeProfileId(); + if (!profileId) return []; + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const current = database.transaction("messages", "readonly"); + const request = current.objectStore("messages").index("profileId").getAllKeys(IDBKeyRange.only(profileId)); + request.onsuccess = () => resolve(request.result.map((key) => String(key).slice(profileId.length + 1))); + request.onerror = () => reject(request.error || new Error("Unable to list local objects")); + current.oncomplete = () => database.close(); + }); +} + +export async function listCachedMessages() { + const profileId = activeProfileId(); + if (!profileId) return []; + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const current = database.transaction("messages", "readonly"); + const request = current.objectStore("messages").index("profileId").getAll(profileId); + request.onsuccess = () => resolve((request.result as CachedMessage[]).map(({cacheKey: _cacheKey, profileId: _profileId, ...message}) => message)); + request.onerror = () => reject(request.error || new Error("Unable to list local message objects")); + current.oncomplete = () => database.close(); + }); +} + +function repositoryOutboxKey(profileId: string, conversationId: string) { + return `${profileId}:${conversationId}:repository`; +} + +export async function commitLocalMessage(conversationId: string, message: StoredChatMessage) { + const profileId = activeProfileId(); + if (!profileId) throw new Error("Local repository profile is unavailable"); + const database = await openDatabase(); + await new Promise((resolve, reject) => { + const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite"); + const refs = current.objectStore("conversations"); + const objects = current.objectStore("messages"); + const reflog = current.objectStore("reflog"); + const outbox = current.objectStore("repositoryOutbox"); + const refKey = profileCacheKey(profileId, conversationId); + const outboxKey = repositoryOutboxKey(profileId, conversationId); + const refRequest = refs.get(refKey); + refRequest.onsuccess = () => { + const ref = refRequest.result as CachedConversationRef | undefined; + if (!ref) { + current.abort(); + reject(new Error("Local conversation ref is unavailable")); + return; + } + if (ref.headMessageId !== message.parentMessageId) { + current.abort(); + reject(new Error("Local conversation head changed")); + return; + } + const existingOutboxRequest = outbox.get(outboxKey); + existingOutboxRequest.onsuccess = () => { + const timestamp = new Date().toISOString(); + const existing = existingOutboxRequest.result as RepositoryOutboxRecord | undefined; + const object: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId}; + objects.put(object); + refs.put({...ref, headMessageId: message.id, messageCount: ref.messageCount + 1, updatedAt: timestamp}); + const log: CachedReflog = { + cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`, + profileId, + conversationId, + oldHeadMessageId: ref.headMessageId, + newHeadMessageId: message.id, + reason: "commit", + createdAt: timestamp + }; + reflog.put(log); + outbox.put({ + cacheKey: outboxKey, + profileId, + conversationId, + objectIds: [...new Set([...(existing?.objectIds || []), message.id])], + expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null, + expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0, + expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0, + createdAt: existing?.createdAt || timestamp, + updatedAt: timestamp + } satisfies RepositoryOutboxRecord); + }; + }; + current.oncomplete = () => { database.close(); resolve(); }; + current.onerror = () => { database.close(); reject(current.error || new Error("Local commit failed")); }; + current.onabort = () => database.close(); + }); + const conversation = await loadCachedConversation(conversationId); + if (!conversation) throw new Error("Local commit could not be loaded"); + await cacheConversationSummariesFromConversation(conversation); + return conversation; +} + +export async function moveLocalConversationHead(conversationId: string, headMessageId: string | null) { + const profileId = activeProfileId(); + if (!profileId) throw new Error("Local repository profile is unavailable"); + const conversation = await loadCachedConversation(conversationId); + if (!conversation) throw new Error("Local conversation ref is unavailable"); + const targetPath = await messagePathFromCache(profileId, headMessageId); + if (headMessageId !== null && targetPath.at(-1)?.id !== headMessageId) throw new Error("Target message is unavailable in the local graph"); + if (conversation.headMessageId === headMessageId) return conversation; + + const database = await openDatabase(); + await new Promise((resolve, reject) => { + const current = database.transaction(["conversations", "reflog", "repositoryOutbox"], "readwrite"); + const refs = current.objectStore("conversations"); + const reflog = current.objectStore("reflog"); + const outbox = current.objectStore("repositoryOutbox"); + const refKey = profileCacheKey(profileId, conversationId); + const outboxKey = repositoryOutboxKey(profileId, conversationId); + const refRequest = refs.get(refKey); + refRequest.onsuccess = () => { + const ref = refRequest.result as CachedConversationRef | undefined; + if (!ref || ref.headMessageId !== conversation.headMessageId) { + current.abort(); + reject(new Error("Local conversation head changed")); + return; + } + const outboxRequest = outbox.get(outboxKey); + outboxRequest.onsuccess = () => { + const timestamp = new Date().toISOString(); + const existing = outboxRequest.result as RepositoryOutboxRecord | undefined; + refs.put({...ref, headMessageId, messageCount: targetPath.length, updatedAt: timestamp}); + reflog.put({ + cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`, + profileId, + conversationId, + oldHeadMessageId: ref.headMessageId, + newHeadMessageId: headMessageId, + reason: "reset", + createdAt: timestamp + } satisfies CachedReflog); + outbox.put({ + cacheKey: outboxKey, + profileId, + conversationId, + objectIds: existing?.objectIds || [], + expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null, + expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0, + expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0, + createdAt: existing?.createdAt || timestamp, + updatedAt: timestamp + } satisfies RepositoryOutboxRecord); + }; + }; + current.oncomplete = () => { database.close(); resolve(); }; + current.onerror = () => { database.close(); reject(current.error || new Error("Unable to move local conversation head")); }; + current.onabort = () => database.close(); + }); + const updated = await loadCachedConversation(conversationId); + if (!updated) throw new Error("Moved conversation could not be loaded"); + await cacheConversationSummariesFromConversation(updated); + return updated; +} + +async function cacheConversationSummariesFromConversation(conversation: Conversation) { + const summary: ConversationSummary = { + id: conversation.id, + name: conversation.name, + headMessageId: conversation.headMessageId, + providerId: conversation.providerId, + model: conversation.model, + messageCount: conversation.messageCount, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + upstreamHeadMessageId: conversation.upstreamHeadMessageId, + headVersion: conversation.headVersion, + metadataVersion: conversation.metadataVersion + }; + await cacheConversationSummaries([summary, ...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id)] + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); +} + +export async function createLocalConversation(conversation: Conversation) { + const profileId = activeProfileId(); + if (!profileId) throw new Error("Local repository profile is unavailable"); + const timestamp = new Date().toISOString(); + const local: Conversation = {...conversation, upstreamHeadMessageId: null, headVersion: 0, metadataVersion: 0}; + const database = await openDatabase(); + await new Promise((resolve, reject) => { + const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite"); + const refs = current.objectStore("conversations"); + const objects = current.objectStore("messages"); + const reflog = current.objectStore("reflog"); + const outbox = current.objectStore("repositoryOutbox"); + for (const message of local.messages) objects.put({...message, cacheKey: profileCacheKey(profileId, message.id), profileId} satisfies CachedMessage); + const {messages: _messages, ...summary} = local; + refs.put({...summary, cacheKey: profileCacheKey(profileId, local.id), profileId} satisfies CachedConversationRef); + reflog.put({ + cacheKey: `${profileId}:${local.id}:${timestamp}:${crypto.randomUUID()}`, + profileId, + conversationId: local.id, + oldHeadMessageId: null, + newHeadMessageId: local.headMessageId, + reason: "create", + createdAt: timestamp + } satisfies CachedReflog); + outbox.put({ + cacheKey: repositoryOutboxKey(profileId, local.id), + profileId, + conversationId: local.id, + objectIds: local.messages.filter((message) => message.id.startsWith("sha256:")).map((message) => message.id), + expectedHeadMessageId: null, + expectedHeadVersion: 0, + expectedMetadataVersion: 0, + createdAt: timestamp, + updatedAt: timestamp + } satisfies RepositoryOutboxRecord); + current.oncomplete = () => { database.close(); resolve(); }; + current.onerror = () => { database.close(); reject(current.error || new Error("Unable to create local ref")); }; + }); + await cacheConversationSummariesFromConversation(local); + return local; +} + +export async function queueLocalRefUpdate(conversation: Conversation) { + const profileId = activeProfileId(); + if (!profileId) throw new Error("Local repository profile is unavailable"); + await cacheConversation(conversation); + const key = repositoryOutboxKey(profileId, conversation.id); + const existing = await transaction("repositoryOutbox", "readonly", (store) => store.get(key)); + const timestamp = new Date().toISOString(); + const record: RepositoryOutboxRecord = { + cacheKey: key, + profileId, + conversationId: conversation.id, + objectIds: existing?.objectIds || [], + expectedHeadMessageId: existing?.expectedHeadMessageId ?? conversation.upstreamHeadMessageId ?? null, + expectedHeadVersion: existing?.expectedHeadVersion ?? conversation.headVersion ?? 0, + expectedMetadataVersion: existing?.expectedMetadataVersion ?? conversation.metadataVersion ?? 0, + createdAt: existing?.createdAt || timestamp, + updatedAt: timestamp + }; + await transaction("repositoryOutbox", "readwrite", (store) => store.put(record)); + return conversation; +} + +export async function repositoryPushPayload() { + const profileId = activeProfileId(); + if (!profileId) return {repositoryId: "", objects: [] as StoredChatMessage[], refs: [] as RepositoryRefUpdate[]}; + const database = await openDatabase(); + const outbox = await new Promise((resolve, reject) => { + const current = database.transaction("repositoryOutbox", "readonly"); + const request = current.objectStore("repositoryOutbox").index("profileId").getAll(profileId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + current.oncomplete = () => database.close(); + }); + const objects: StoredChatMessage[] = []; + const refs: RepositoryRefUpdate[] = []; + for (const pending of outbox) { + const conversation = await loadCachedConversation(pending.conversationId); + if (!conversation) continue; + for (const id of pending.objectIds) { + const object = await loadCachedMessage(profileId, id); + if (object) objects.push(object); + } + refs.push({ + conversationId: conversation.id, + expectedHeadMessageId: pending.expectedHeadMessageId, + expectedHeadVersion: pending.expectedHeadVersion, + expectedMetadataVersion: pending.expectedMetadataVersion, + headMessageId: conversation.headMessageId, + name: conversation.name, + providerId: conversation.providerId, + model: conversation.model, + generationSettings: conversation.generationSettings, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt + }); + } + return {repositoryId: profileId, objects, refs}; +} + +export async function applyRepositoryFetch(repository: RepositoryFetch) { + const profileId = activeProfileId(); + if (!profileId) return; + for (const object of repository.objects) { + await transaction("messages", "readwrite", (store) => store.put({...object, cacheKey: profileCacheKey(profileId, object.id), profileId})); + } + for (const remote of repository.refs) { + const local = await loadCachedConversation(remote.id); + const pending = await transaction("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, remote.id))); + const canFastForward = !local || (!pending && local.headMessageId === (local.upstreamHeadMessageId ?? local.headMessageId)); + const headMessageId = canFastForward ? remote.headMessageId : local!.headMessageId; + const messages = await messagePathFromCache(profileId, headMessageId); + const conversation: Conversation = { + id: remote.id, + name: pending && local ? local.name : remote.name, + headMessageId, + upstreamHeadMessageId: remote.headMessageId, + providerId: pending && local ? local.providerId : remote.providerId, + model: pending && local ? local.model : remote.model, + generationSettings: pending && local ? local.generationSettings : remote.generationSettings, + headVersion: remote.headVersion, + metadataVersion: remote.metadataVersion, + messageCount: messages.length, + createdAt: remote.createdAt, + updatedAt: pending && local ? local.updatedAt : remote.updatedAt, + messages + }; + await cacheConversation(conversation); + } + const remoteIds = new Set(repository.refs.map((ref) => ref.id)); + for (const local of await loadCachedConversationSummaries()) { + if (remoteIds.has(local.id)) continue; + const pending = await transaction("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, local.id))); + if (!pending && ((local.headVersion || 0) > 0 || (local.metadataVersion || 0) > 0)) await removeCachedConversation(local.id); + } + await recordRepositoryFetch(repository.fetchedAt); +} + +async function messagePathFromCache(profileId: string, headMessageId: string | null) { + const reversed: StoredChatMessage[] = []; + const seen = new Set(); + let id = headMessageId; + while (id) { + if (seen.has(id) || reversed.length >= 500) throw new Error("Local object history is cyclic or too long"); + seen.add(id); + const object = await loadCachedMessage(profileId, id); + if (!object) throw new Error(`Local object ${id} is unavailable`); + reversed.push(object); + id = object.parentMessageId; + } + return reversed.reverse(); +} + +export async function applyRepositoryPushResults(results: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>) { + const profileId = activeProfileId(); + if (!profileId) return; + for (const result of results) { + const local = await loadCachedConversation(result.conversationId); + if (!local || !result.ref) continue; + if (result.status === "ok") { + await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion}); + const key = repositoryOutboxKey(profileId, result.conversationId); + if (local.headMessageId === result.ref.headMessageId) { + await transaction("repositoryOutbox", "readwrite", (store) => store.delete(key)); + } else { + const pending = await transaction("repositoryOutbox", "readonly", (store) => store.get(key)); + if (pending) await transaction("repositoryOutbox", "readwrite", (store) => store.put({ + ...pending, + expectedHeadMessageId: result.ref!.headMessageId, + expectedHeadVersion: result.ref!.headVersion, + expectedMetadataVersion: result.ref!.metadataVersion + })); + } + } else { + await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion}); + } + } } export async function queueConversationChange(change: Omit) { @@ -148,7 +738,7 @@ export async function queueConversationChange(change: Omit { + test("keeps requests within server limits", () => { + const objects = Array.from({length: 2001}, (_, index) => ({id: `sha256:${index}`})); + const refs = Array.from({length: 201}, (_, index) => ({conversationId: String(index)})); + const batches = repositoryPushBatches({repositoryId: "local:test", objects, refs}); + expect(batches.map((batch) => [batch.objects.length, batch.refs.length])).toEqual([ + [1000, 0], [1000, 0], [1, 0], [0, 100], [0, 100], [0, 1] + ]); + }); + + test("deduplicates shared message objects", () => { + const object = {id: "sha256:shared"}; + const batches = repositoryPushBatches({repositoryId: "local:test", objects: [object, object], refs: []}); + expect(batches).toHaveLength(1); + expect(batches[0].objects).toHaveLength(1); + }); +}); diff --git a/chat/lib/repository-push-batches.ts b/chat/lib/repository-push-batches.ts new file mode 100644 index 0000000..cb8f9b0 --- /dev/null +++ b/chat/lib/repository-push-batches.ts @@ -0,0 +1,21 @@ +import type {RepositoryRefUpdate, StoredChatMessage} from "./conversation-types"; + +export type RepositoryPushPayload = { + repositoryId: string; + objects: StoredChatMessage[]; + refs: RepositoryRefUpdate[]; +}; + +function chunks(items: T[], size: number) { + const result: T[][] = []; + for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size)); + return result; +} + +export function repositoryPushBatches(payload: RepositoryPushPayload) { + const objectBatches = chunks([...new Map(payload.objects.map((object) => [object.id, object])).values()], 1000) + .map((objects) => ({repositoryId: payload.repositoryId, objects, refs: [] as RepositoryRefUpdate[]})); + const refBatches = chunks(payload.refs, 100) + .map((refs) => ({repositoryId: payload.repositoryId, objects: [] as StoredChatMessage[], refs})); + return [...objectBatches, ...refBatches]; +} diff --git a/chat/lib/response-metadata.test.mjs b/chat/lib/response-metadata.test.mjs index dbf6ac6..9cf8363 100644 --- a/chat/lib/response-metadata.test.mjs +++ b/chat/lib/response-metadata.test.mjs @@ -16,4 +16,11 @@ describe("response metadata", () => { expect(metadata.outputTokens).toBeNull(); expect(metadata.tokensPerSecond).toBeNull(); }); + + test("falls back to estimated tokens when provider output is absent", () => { + const metadata = responseMetadata("local", "model", performance.now() - 2000, undefined, 80); + expect(metadata.outputTokens).toBe(80); + expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(39); + expect(metadata.tokensPerSecond).toBeLessThanOrEqual(41); + }); }); diff --git a/chat/lib/response-metadata.ts b/chat/lib/response-metadata.ts index 1a4ad0f..609083c 100644 --- a/chat/lib/response-metadata.ts +++ b/chat/lib/response-metadata.ts @@ -1,10 +1,24 @@ import type {ResponseMetadata} from "./conversation-types"; -export function responseMetadata(providerId: string, model: string, startedAt: number, outputTokens: number | undefined | null): ResponseMetadata { +function normalizeTokenCount(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value); + if (typeof value === "bigint" && value >= 0n) return Number(value); + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) return Math.round(parsed); + } + return null; +} + +export function responseMetadata( + providerId: string, + model: string, + startedAt: number, + outputTokens: number | undefined | null, + fallbackOutputTokens?: number | undefined | null +): ResponseMetadata { const durationMs = Math.max(1, Math.round(performance.now() - startedAt)); - const normalizedTokens = typeof outputTokens === "number" && Number.isFinite(outputTokens) && outputTokens >= 0 - ? Math.round(outputTokens) - : null; + const normalizedTokens = normalizeTokenCount(outputTokens) ?? normalizeTokenCount(fallbackOutputTokens); const tokensPerSecond = normalizedTokens === null ? null : Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10; diff --git a/chat/lib/session-transfer.test.mjs b/chat/lib/session-transfer.test.mjs new file mode 100644 index 0000000..a52b784 --- /dev/null +++ b/chat/lib/session-transfer.test.mjs @@ -0,0 +1,81 @@ +import {describe, expect, test} from "bun:test"; +import { + currentPath, + detectSessionTransferFormat, + parseSessionTransfer, + serializeSessionTransfer, + serializeXitengArchive +} from "./session-transfer.ts"; + +const lines = (values) => `${values.map((value) => JSON.stringify(value)).join("\n")}\n`; + +describe("session transfer formats", () => { + test("imports Codex rollout records without duplicate event messages", () => { + const text = lines([ + {type: "session_meta", timestamp: "2026-08-13T00:00:00Z", payload: {id: "11111111-1111-4111-8111-111111111111", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"}}, + {type: "turn_context", timestamp: "2026-08-13T00:00:00Z", payload: {model: "gpt-5.6-sol"}}, + {type: "response_item", timestamp: "2026-08-13T00:00:01Z", payload: {type: "message", role: "user", content: [{type: "input_text", text: "hello"}]}}, + {type: "event_msg", timestamp: "2026-08-13T00:00:01Z", payload: {type: "user_message", message: "hello"}}, + {type: "response_item", timestamp: "2026-08-13T00:00:02Z", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "hi"}]}}, + {type: "event_msg", timestamp: "2026-08-13T00:00:02Z", payload: {type: "agent_message", message: "hi"}} + ]); + const parsed = parseSessionTransfer(text, "rollout.jsonl"); + expect(parsed.format).toBe("codex"); + expect(parsed.nodes.map((node) => node.role)).toEqual(["user", "assistant"]); + expect(parsed.sessions[0].model).toBe("gpt-5.6-sol"); + }); + + test("preserves Claude parent branches", () => { + const text = lines([ + {type: "user", sessionId: "s1", uuid: "u1", parentUuid: null, timestamp: "2026-08-13T00:00:00Z", message: {role: "user", content: "question"}}, + {type: "assistant", sessionId: "s1", uuid: "a1", parentUuid: "u1", timestamp: "2026-08-13T00:00:01Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "first"}]}}, + {type: "assistant", sessionId: "s1", uuid: "a2", parentUuid: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "second"}]}}, + {type: "custom-title", sessionId: "s1", customTitle: "branched"} + ]); + const parsed = parseSessionTransfer(text, "claude.jsonl"); + expect(parsed.nodes.map((node) => node.parentSourceId)).toEqual([null, "u1", "u1"]); + expect(parsed.sessions[0].headSourceId).toBe("a2"); + expect(parsed.sessions[0].name).toBe("branched"); + }); + + test("imports current OMP title slot and tree", () => { + const text = lines([ + {type: "title", v: 1, title: "OMP task", updatedAt: "2026-08-13T00:00:00Z", pad: ""}, + {type: "session", version: 3, id: "omp-1", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"}, + {type: "message", id: "u1", parentId: null, timestamp: "2026-08-13T00:00:01Z", message: {role: "user", content: [{type: "text", text: "hello"}]}}, + {type: "message", id: "a1", parentId: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", content: [{type: "text", text: "hi"}]}} + ]); + const parsed = parseSessionTransfer(text, "omp.jsonl"); + expect(parsed.sessions[0].name).toBe("OMP task"); + expect(currentPath(parsed.nodes, "a1").map((node) => node.sourceId)).toEqual(["u1", "a1"]); + }); + + test("exports parseable Codex, Claude and OMP JSONL", () => { + const document = { + format: "xiteng", + sessions: [{sourceId: "22222222-2222-4222-8222-222222222222", name: "round trip", headSourceId: "a1", providerId: "openai", model: "gpt-5.6", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:02Z"}], + nodes: [ + {sourceId: "u1", parentSourceId: null, role: "user", parts: [{type: "text", text: "hello"}], createdAt: "2026-08-13T00:00:01Z", completedAt: "2026-08-13T00:00:01Z"}, + {sourceId: "a1", parentSourceId: "u1", role: "assistant", parts: [{type: "reasoning", text: "brief"}, {type: "text", text: "hi"}], createdAt: "2026-08-13T00:00:02Z", completedAt: "2026-08-13T00:00:02Z"} + ] + }; + for (const format of ["codex", "claude", "omp"]) { + const exported = serializeSessionTransfer(document, format); + expect(detectSessionTransferFormat(exported)).toBe(format); + const imported = parseSessionTransfer(exported); + expect(imported.nodes.some((node) => node.parts.some((part) => part.type === "text" && part.text === "hi"))).toBe(true); + } + }); + + test("native archive keeps graph objects and working items", () => { + const text = serializeXitengArchive([ + {id: "c1", name: "native", headMessageId: "m1", providerId: "p", model: "m", messageCount: 1, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:01Z", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, messages: []} + ], [ + {id: "m1", parentMessageId: null, role: "user", parts: [{type: "text", text: "draft"}], origin: {type: "user"}, completion: {status: "complete"}, createdAt: "2026-08-13T00:00:00Z", completedAt: "2026-08-13T00:00:00Z"} + ], []); + const parsed = parseSessionTransfer(text, "backup.xiteng-chat.json"); + expect(parsed.format).toBe("xiteng"); + expect(parsed.sessions).toHaveLength(1); + expect(parsed.nodes).toHaveLength(1); + }); +}); diff --git a/chat/lib/session-transfer.ts b/chat/lib/session-transfer.ts new file mode 100644 index 0000000..ab9265a --- /dev/null +++ b/chat/lib/session-transfer.ts @@ -0,0 +1,485 @@ +import type {Conversation, StoredChatMessage, WorkingItem} from "./conversation-types"; +import {defaultGenerationSettings, normalizeGenerationSettings, type GenerationSettings} from "./generation-settings"; + +export type SessionTransferFormat = "xiteng" | "codex" | "claude" | "omp"; + +export type TransferNode = { + sourceId: string; + parentSourceId: string | null; + role: StoredChatMessage["role"]; + parts: StoredChatMessage["parts"]; + createdAt: string; + completedAt: string; + completion?: StoredChatMessage["completion"]; + origin?: StoredChatMessage["origin"]; + metadata?: StoredChatMessage["metadata"]; +}; + +export type TransferSession = { + sourceId: string; + name: string; + headSourceId: string | null; + providerId: string; + model: string; + generationSettings: GenerationSettings; + createdAt: string; + updatedAt: string; +}; + +export type TransferDocument = { + format: SessionTransferFormat; + sessions: TransferSession[]; + nodes: TransferNode[]; + workingItems?: WorkingItem[]; +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown, fallback = "") { + return typeof value === "string" ? value : fallback; +} + +function isoTimestamp(value: unknown, fallback = new Date().toISOString()) { + if (typeof value === "number" && Number.isFinite(value)) { + const milliseconds = value < 10_000_000_000 ? value * 1000 : value; + return new Date(milliseconds).toISOString(); + } + if (typeof value === "string" && Number.isFinite(Date.parse(value))) return new Date(value).toISOString(); + return fallback; +} + +function jsonLines(text: string) { + const values: JsonRecord[] = []; + for (const [index, line] of text.split(/\r?\n/).entries()) { + if (!line.trim()) continue; + try { + const value: unknown = JSON.parse(line); + if (isRecord(value)) values.push(value); + } catch { + throw new Error(`JSONL 第 ${index + 1} 行无法解析`); + } + } + return values; +} + +function contentParts(value: unknown, textTypes = ["text", "input_text", "output_text"]): StoredChatMessage["parts"] { + if (typeof value === "string") return value ? [{type: "text", text: value}] : []; + if (!Array.isArray(value)) return []; + const parts: StoredChatMessage["parts"] = []; + for (const candidate of value) { + if (!isRecord(candidate)) continue; + if (textTypes.includes(stringValue(candidate.type)) && typeof candidate.text === "string") { + parts.push({type: "text", text: candidate.text}); + } else if (candidate.type === "thinking" && typeof candidate.thinking === "string") { + parts.push({type: "reasoning", text: candidate.thinking, ...(typeof candidate.signature === "string" ? {signature: candidate.signature} : {})}); + } else if (candidate.type === "image" && isRecord(candidate.source)) { + const data = stringValue(candidate.source.data); + const mediaType = stringValue(candidate.source.media_type); + if (data && mediaType) parts.push({type: "image", data, mimeType: mediaType}); + } else if (candidate.type === "input_image" && typeof candidate.image_url === "string") { + parts.push({type: "image-url", url: candidate.image_url, ...(typeof candidate.detail === "string" ? {detail: candidate.detail} : {})}); + } + } + return parts; +} + +function firstText(nodes: TransferNode[]) { + for (const node of nodes) { + if (node.role !== "user") continue; + const text = node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join("").trim(); + if (text && !/^<(environment_context|permissions instructions)>/i.test(text) && !/^# AGENTS\.md instructions/i.test(text)) return text.replace(/\s+/g, " ").slice(0, 80); + } + return "导入的会话"; +} + +function newestLeaf(nodes: TransferNode[]) { + const parents = new Set(nodes.map((node) => node.parentSourceId).filter((id): id is string => Boolean(id))); + return [...nodes].filter((node) => !parents.has(node.sourceId)).sort((left, right) => right.completedAt.localeCompare(left.completedAt))[0]?.sourceId || nodes.at(-1)?.sourceId || null; +} + +function transferNode(sourceId: string, parentSourceId: string | null, role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string, extra: Partial = {}): TransferNode { + return { + sourceId, + parentSourceId, + role, + parts, + createdAt: timestamp, + completedAt: timestamp, + completion: {status: "complete"}, + origin: role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "session-import"} : {type: "legacy"}, + ...extra + }; +} + +function parseXiteng(value: JsonRecord): TransferDocument { + if (value.type !== "xiteng-chat-archive" || value.version !== 1 || !Array.isArray(value.conversations) || !Array.isArray(value.objects)) { + throw new Error("不支持的 Xiteng Chat 备份版本"); + } + const nodes: TransferNode[] = []; + for (const candidate of value.objects) { + if (!isRecord(candidate) || typeof candidate.id !== "string" || !["system", "user", "assistant"].includes(String(candidate.role)) || !Array.isArray(candidate.parts)) continue; + nodes.push({ + sourceId: candidate.id, + parentSourceId: typeof candidate.parentMessageId === "string" ? candidate.parentMessageId : null, + role: candidate.role as StoredChatMessage["role"], + parts: candidate.parts as StoredChatMessage["parts"], + origin: isRecord(candidate.origin) ? candidate.origin as StoredChatMessage["origin"] : {type: "legacy"}, + completion: isRecord(candidate.completion) ? candidate.completion as StoredChatMessage["completion"] : {status: "complete"}, + createdAt: isoTimestamp(candidate.createdAt), + completedAt: isoTimestamp(candidate.completedAt, isoTimestamp(candidate.createdAt)), + ...(isRecord(candidate.metadata) ? {metadata: candidate.metadata as StoredChatMessage["metadata"]} : {}) + }); + } + const sessions: TransferSession[] = value.conversations.flatMap((candidate) => { + if (!isRecord(candidate) || typeof candidate.id !== "string") return []; + return [{ + sourceId: candidate.id, + name: stringValue(candidate.name), + headSourceId: typeof candidate.headMessageId === "string" ? candidate.headMessageId : null, + providerId: stringValue(candidate.providerId, "imported"), + model: stringValue(candidate.model, "imported"), + generationSettings: normalizeGenerationSettings(candidate.generationSettings), + createdAt: isoTimestamp(candidate.createdAt), + updatedAt: isoTimestamp(candidate.updatedAt) + }]; + }); + return {format: "xiteng", sessions, nodes, workingItems: Array.isArray(value.workingItems) ? value.workingItems as WorkingItem[] : []}; +} + +function parseCodex(records: JsonRecord[]): TransferDocument { + const metadata = records.find((record) => record.type === "session_meta" && isRecord(record.payload)); + const sessionPayload = metadata && isRecord(metadata.payload) ? metadata.payload : {}; + const sessionId = stringValue(sessionPayload.id, crypto.randomUUID()); + let model = "codex"; + let name = ""; + let parentSourceId: string | null = null; + let ordinal = 0; + const nodes: TransferNode[] = []; + const canonicalUserText = new Set(); + const canonicalAssistantText = new Set(); + for (const record of records) { + if (record.type !== "response_item" || !isRecord(record.payload) || record.payload.type !== "message") continue; + const text = contentParts(record.payload.content).filter((part) => part.type === "text").map((part) => String(part.text || "")).join(""); + if (record.payload.role === "user") canonicalUserText.add(text); + if (record.payload.role === "assistant") canonicalAssistantText.add(text); + } + const pushNode = (role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string) => { + if (!parts.length) return; + const sourceId = `codex-${sessionId}-${++ordinal}`; + nodes.push(transferNode(sourceId, parentSourceId, role, parts, timestamp)); + parentSourceId = sourceId; + }; + for (const record of records) { + const timestamp = isoTimestamp(record.timestamp, isoTimestamp(sessionPayload.timestamp)); + if (!isRecord(record.payload)) continue; + const payload = record.payload; + if (record.type === "turn_context" && typeof payload.model === "string") model = payload.model; + if (record.type === "response_item") { + if (payload.type === "message" && (payload.role === "user" || payload.role === "assistant")) { + const parts = contentParts(payload.content); + const text = parts.filter((part) => part.type === "text").map((part) => String(part.text || "")).join("").trim(); + if (payload.role !== "user" || !/^<(environment_context|permissions instructions)>/i.test(text)) pushNode(payload.role, parts, timestamp); + } else if (payload.type === "reasoning") { + const parts = [...(Array.isArray(payload.summary) ? payload.summary : []), ...(Array.isArray(payload.content) ? payload.content : [])] + .flatMap((item) => isRecord(item) && typeof item.text === "string" ? [{type: "reasoning", text: item.text}] : []); + pushNode("assistant", parts, timestamp); + } else if (["function_call", "custom_tool_call", "web_search_call", "tool_search_call"].includes(String(payload.type))) { + const callId = stringValue(payload.call_id, stringValue(payload.id, `call-${ordinal + 1}`)); + const toolName = stringValue(payload.name, payload.type === "web_search_call" ? "web_search" : payload.type === "tool_search_call" ? "tool_search" : "unknown"); + let argumentsValue: unknown = payload.type === "custom_tool_call" ? payload.input : payload.arguments ?? payload.action; + if (typeof argumentsValue === "string") { + try { argumentsValue = JSON.parse(argumentsValue); } catch { argumentsValue = {input: argumentsValue}; } + } + pushNode("assistant", [{type: "tool-call", id: callId, name: toolName, arguments: isRecord(argumentsValue) ? argumentsValue : {}}], timestamp); + } else if (["function_call_output", "custom_tool_call_output", "tool_search_output"].includes(String(payload.type))) { + pushNode("assistant", [{type: "tool-result", toolCallId: stringValue(payload.call_id), content: payload.output ?? payload.tools, isError: payload.status === "failed"}], timestamp); + } + } else if (record.type === "event_msg") { + if (payload.type === "thread_name_updated" && typeof payload.thread_name === "string") name = payload.thread_name; + if (payload.type === "user_message" && typeof payload.message === "string" && !canonicalUserText.has(payload.message)) pushNode("user", [{type: "text", text: payload.message}], timestamp); + if (payload.type === "agent_message" && typeof payload.message === "string" && !canonicalAssistantText.has(payload.message)) pushNode("assistant", [{type: "text", text: payload.message}], timestamp); + } + } + const createdAt = isoTimestamp(sessionPayload.timestamp, nodes[0]?.createdAt); + return { + format: "codex", + nodes, + sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: parentSourceId, providerId: "openai", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}] + }; +} + +function nearestRetainedParent(sourceParentId: string | null, sourceParents: Map, retained: Map) { + const seen = new Set(); + let cursor = sourceParentId; + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + const retainedId = retained.get(cursor); + if (retainedId) return retainedId; + cursor = sourceParents.get(cursor) || null; + } + return null; +} + +function parseClaude(records: JsonRecord[]): TransferDocument { + const sourceParents = new Map(); + for (const record of records) if (typeof record.uuid === "string") sourceParents.set(record.uuid, typeof record.parentUuid === "string" ? record.parentUuid : null); + const retained = new Map(); + const nodes: TransferNode[] = []; + let sessionId = ""; + let name = ""; + let model = "claude"; + for (const [index, record] of records.entries()) { + sessionId ||= stringValue(record.sessionId); + if (record.type === "custom-title") name = stringValue(record.customTitle, name); + if (record.type === "ai-title" && !name) name = stringValue(record.aiTitle, name); + if ((record.type !== "user" && record.type !== "assistant") || record.isSidechain === true || record.isMeta === true || !isRecord(record.message)) continue; + const sourceUuid = stringValue(record.uuid, `claude-line-${index + 1}`); + let parentSourceId = nearestRetainedParent(typeof record.parentUuid === "string" ? record.parentUuid : null, sourceParents, retained); + const timestamp = isoTimestamp(record.timestamp); + const converted: TransferNode[] = []; + if (record.type === "assistant") { + model = stringValue(record.message.model, model); + const parts = contentParts(record.message.content); + if (Array.isArray(record.message.content)) { + for (const block of record.message.content) { + if (!isRecord(block) || block.type !== "tool_use") continue; + parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.input) ? block.input : {}}); + } + } + if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "assistant", parts, timestamp)); + } else { + const toolResults = Array.isArray(record.message.content) ? record.message.content.filter((block) => isRecord(block) && block.type === "tool_result") : []; + if (toolResults.length) { + for (const block of toolResults) { + if (!isRecord(block)) continue; + converted.push(transferNode(`${sourceUuid}-tool-result-${converted.length}`, parentSourceId, "assistant", [{type: "tool-result", toolCallId: stringValue(block.tool_use_id), content: block.content, isError: block.is_error === true}], timestamp)); + parentSourceId = converted.at(-1)!.sourceId; + } + } else { + const parts = contentParts(record.message.content); + if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "user", parts, timestamp)); + } + } + for (const node of converted) nodes.push(node); + const tail = converted.at(-1)?.sourceId; + if (tail) retained.set(sourceUuid, tail); + } + sessionId ||= crypto.randomUUID(); + const createdAt = nodes[0]?.createdAt || new Date().toISOString(); + return {format: "claude", nodes, sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: newestLeaf(nodes), providerId: "anthropic", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]}; +} + +function ompMessageParts(message: JsonRecord) { + const parts = contentParts(message.content); + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (!isRecord(block)) continue; + if (block.type === "toolCall") parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.arguments) ? block.arguments : {}}); + } + } + return parts; +} + +function parseOmp(records: JsonRecord[]): TransferDocument { + const header = records.find((record) => record.type === "session"); + if (!header) throw new Error("OMP JSONL 缺少 session header"); + const titleSlot = records.find((record) => record.type === "title"); + const titleChanges = records.filter((record) => record.type === "title_change"); + const sourceParents = new Map(); + for (const record of records) if (typeof record.id === "string" && record.type !== "session") sourceParents.set(record.id, typeof record.parentId === "string" ? record.parentId : null); + const retained = new Map(); + const nodes: TransferNode[] = []; + let model = "omp"; + for (const record of records) { + if (record.type === "model_change" && typeof record.model === "string") model = record.model.includes("/") ? record.model.slice(record.model.indexOf("/") + 1) : record.model; + if (record.type !== "message" || typeof record.id !== "string" || !isRecord(record.message)) continue; + const parentSourceId = nearestRetainedParent(typeof record.parentId === "string" ? record.parentId : null, sourceParents, retained); + const timestamp = isoTimestamp(record.timestamp); + const role = record.message.role; + let parts: StoredChatMessage["parts"] = []; + if (role === "toolResult") parts = [{type: "tool-result", toolCallId: stringValue(record.message.toolCallId), toolName: stringValue(record.message.toolName, "unknown"), content: record.message.content, isError: record.message.isError === true}]; + else parts = ompMessageParts(record.message); + if (!parts.length || (role !== "user" && role !== "assistant" && role !== "toolResult")) continue; + nodes.push(transferNode(record.id, parentSourceId, role === "toolResult" ? "assistant" : role, parts, timestamp)); + retained.set(record.id, record.id); + } + const createdAt = isoTimestamp(header.timestamp, nodes[0]?.createdAt); + const latestTitle = titleChanges.at(-1); + const name = stringValue(latestTitle?.title, stringValue(titleSlot?.title, stringValue(header.title, firstText(nodes)))); + return {format: "omp", nodes, sessions: [{sourceId: stringValue(header.id, crypto.randomUUID()), name, headSourceId: nodes.at(-1)?.sourceId || null, providerId: model.includes("claude") ? "anthropic" : model.includes("gpt") || model.includes("codex") ? "openai" : "imported", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]}; +} + +export function detectSessionTransferFormat(text: string, filename = ""): SessionTransferFormat { + const trimmed = text.trimStart(); + if (trimmed.startsWith("{")) { + try { + const firstLine = JSON.parse(trimmed.split(/\r?\n/, 1)[0]) as unknown; + if (isRecord(firstLine)) { + if (firstLine.type === "xiteng-chat-archive") return "xiteng"; + if (firstLine.type === "session_meta") return "codex"; + if (firstLine.type === "session" || firstLine.type === "title") return "omp"; + if (typeof firstLine.sessionId === "string" || typeof firstLine.uuid === "string" || ["user", "assistant", "custom-title", "ai-title"].includes(String(firstLine.type))) return "claude"; + } + } catch {} + } + const lower = filename.toLowerCase(); + if (lower.endsWith(".xiteng-chat.json")) return "xiteng"; + throw new Error("无法识别会话格式;请选择 Xiteng Chat JSON 或 Codex / Claude Code / OMP JSONL"); +} + +export function parseSessionTransfer(text: string, filename = ""): TransferDocument { + const format = detectSessionTransferFormat(text, filename); + if (format === "xiteng") { + const value: unknown = JSON.parse(text); + if (!isRecord(value)) throw new Error("Xiteng Chat 备份不是 JSON 对象"); + return parseXiteng(value); + } + const records = jsonLines(text); + if (format === "codex") return parseCodex(records); + if (format === "claude") return parseClaude(records); + return parseOmp(records); +} + +function nodeTextParts(node: TransferNode) { + return node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => ({type: node.role === "assistant" ? "output_text" : "input_text", text: String(part.text)})); +} + +function assistantReasoningParts(node: TransferNode) { + return node.parts.filter((part) => part.type === "reasoning" && typeof part.text === "string").map((part) => String(part.text)); +} + +function portableUuid(value: string) { + const match = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(value); + return match?.[0] || crypto.randomUUID(); +} + +function jsonl(records: JsonRecord[]) { + return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`; +} + +function serializeCodex(document: TransferDocument) { + const session = document.sessions[0]; + if (!session) throw new Error("没有可导出的会话"); + const id = portableUuid(session.sourceId); + const records: JsonRecord[] = [{type: "session_meta", timestamp: session.createdAt, payload: {id, timestamp: session.createdAt, cwd: "/workspace", originator: "xiteng-chat", cli_version: "0.147.0", source: "cli", model_provider: session.providerId}}]; + records.push({type: "turn_context", timestamp: session.createdAt, payload: {cwd: "/workspace", model: session.model}}); + for (const node of currentPath(document.nodes, session.headSourceId)) { + const text = nodeTextParts(node); + if (text.length) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "message", role: node.role, content: text, ...(node.role === "assistant" ? {phase: "final_answer"} : {})}}); + for (const reasoning of assistantReasoningParts(node)) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "reasoning", summary: [{type: "summary_text", text: reasoning}]}}); + for (const part of node.parts) { + if (part.type === "tool-call") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call", call_id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: JSON.stringify(isRecord(part.arguments) ? part.arguments : {})}}); + if (part.type === "tool-result") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call_output", call_id: stringValue(part.toolCallId), output: part.content ?? ""}}); + } + } + if (session.name) records.push({type: "event_msg", timestamp: session.updatedAt, payload: {type: "thread_name_updated", thread_name: session.name}}); + return jsonl(records); +} + +function claudeContent(node: TransferNode) { + const content: JsonRecord[] = []; + for (const part of node.parts) { + if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text}); + if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text, signature: stringValue(part.signature)}); + if (part.type === "tool-call") content.push({type: "tool_use", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), input: isRecord(part.arguments) ? part.arguments : {}}); + } + return content; +} + +function serializeClaude(document: TransferDocument) { + const session = document.sessions[0]; + if (!session) throw new Error("没有可导出的会话"); + const sessionId = portableUuid(session.sourceId); + const idMap = new Map(document.nodes.map((node) => [node.sourceId, portableUuid(node.sourceId)])); + const records: JsonRecord[] = []; + for (const node of document.nodes) { + const common = {sessionId, uuid: idMap.get(node.sourceId), parentUuid: node.parentSourceId ? idMap.get(node.parentSourceId) || null : null, timestamp: node.createdAt, cwd: "/workspace", version: "2.1.81", gitBranch: ""}; + const toolResults = node.parts.filter((part) => part.type === "tool-result"); + const content = claudeContent(node); + if (toolResults.length) { + records.push({...common, type: "user", message: {role: "user", content: toolResults.map((part) => ({type: "tool_result", tool_use_id: stringValue(part.toolCallId), content: part.content ?? "", is_error: part.isError === true}))}}); + } else if (node.role === "user") { + records.push({...common, type: "user", message: {role: "user", content}}); + } else if (node.role === "assistant" && content.length) { + records.push({...common, type: "assistant", message: {id: `msg_${idMap.get(node.sourceId)?.replaceAll("-", "")}`, type: "message", role: "assistant", model: session.model, content, stop_reason: "end_turn", stop_sequence: null, usage: {input_tokens: 0, output_tokens: 0}}}); + } + } + if (session.name) records.push({type: "custom-title", customTitle: session.name, sessionId}); + return jsonl(records); +} + +function ompContent(node: TransferNode) { + const content: JsonRecord[] = []; + for (const part of node.parts) { + if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text}); + if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text}); + if (part.type === "tool-call") content.push({type: "toolCall", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: isRecord(part.arguments) ? part.arguments : {}}); + } + return content; +} + +function serializeOmp(document: TransferDocument) { + const session = document.sessions[0]; + if (!session) throw new Error("没有可导出的会话"); + const idMap = new Map(document.nodes.map((node, index) => [node.sourceId, `xt${(index + 1).toString(36).padStart(6, "0")}`])); + const records: JsonRecord[] = [{type: "session", version: 3, id: portableUuid(session.sourceId), timestamp: session.createdAt, cwd: "/workspace", title: session.name, titleSource: "user"}]; + let modelParent: string | null = null; + if (session.model) { + modelParent = "xtmodel0"; + records.push({type: "model_change", id: modelParent, parentId: null, timestamp: session.createdAt, model: `${session.providerId}/${session.model}`}); + } + for (const node of document.nodes) { + const parentId = node.parentSourceId ? idMap.get(node.parentSourceId) || null : modelParent; + const toolResult = node.parts.find((part) => part.type === "tool-result"); + if (toolResult) { + records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: "toolResult", toolCallId: stringValue(toolResult.toolCallId), toolName: stringValue(toolResult.toolName, "unknown"), content: toolResult.content ?? "", isError: toolResult.isError === true, timestamp: Date.parse(node.createdAt)}}); + } else { + records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: node.role, content: ompContent(node), ...(node.role === "assistant" ? {api: "openai-responses", provider: session.providerId, model: session.model, usage: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0}}, stopReason: "stop"} : {}), timestamp: Date.parse(node.createdAt)}}); + } + } + return jsonl(records); +} + +export function currentPath(nodes: TransferNode[], headSourceId: string | null) { + const byId = new Map(nodes.map((node) => [node.sourceId, node])); + const reversed: TransferNode[] = []; + const seen = new Set(); + let cursor = headSourceId; + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + const node = byId.get(cursor); + if (!node) break; + reversed.push(node); + cursor = node.parentSourceId; + } + return reversed.reverse(); +} + +export function serializeSessionTransfer(document: TransferDocument, format: Exclude) { + if (format === "codex") return serializeCodex(document); + if (format === "claude") return serializeClaude(document); + return serializeOmp(document); +} + +export function serializeXitengArchive(conversations: Conversation[], objects: StoredChatMessage[], workingItems: WorkingItem[]) { + return JSON.stringify({ + type: "xiteng-chat-archive", + version: 1, + exportedAt: new Date().toISOString(), + conversations: conversations.map(({messages: _messages, ...conversation}) => conversation), + objects, + workingItems + }, null, 2); +} + +export function conversationTransferDocument(conversation: Conversation, nodes: StoredChatMessage[]): TransferDocument { + return { + format: "xiteng", + sessions: [{sourceId: conversation.id, name: conversation.name, headSourceId: conversation.headMessageId, providerId: conversation.providerId, model: conversation.model, generationSettings: conversation.generationSettings, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt}], + nodes: nodes.map((message) => ({sourceId: message.id, parentSourceId: message.parentMessageId, role: message.role, parts: message.parts, origin: message.origin, completion: message.completion, createdAt: message.createdAt, completedAt: message.completedAt, ...(message.metadata ? {metadata: message.metadata} : {})})) + }; +} diff --git a/chat/lib/streaming-markdown.test.mjs b/chat/lib/streaming-markdown.test.mjs new file mode 100644 index 0000000..28c18a4 --- /dev/null +++ b/chat/lib/streaming-markdown.test.mjs @@ -0,0 +1,48 @@ +import {describe, expect, test} from "bun:test"; +import {splitStreamingMarkdown} from "./streaming-markdown.ts"; + +describe("streaming markdown blocks", () => { + test("freezes a paragraph after its blank-line boundary", () => { + const open = splitStreamingMarkdown("first paragraph"); + expect(open.blocks.map((block) => block.stable)).toEqual([false]); + expect(open.stableOffset).toBe(0); + + const closed = splitStreamingMarkdown("first paragraph\n\n"); + expect(closed.blocks.map((block) => block.stable)).toEqual([true]); + expect(closed.stableOffset).toBe("first paragraph\n\n".length); + }); + + test("keeps only the trailing paragraph mutable", () => { + const source = "first paragraph\n\nsecond paragraph"; + const result = splitStreamingMarkdown(source); + expect(result.blocks.map(({source, stable}) => [source, stable])).toEqual([ + ["first paragraph", true], + ["second paragraph", false] + ]); + expect(source.slice(0, result.stableOffset)).toBe("first paragraph\n\n"); + }); + + test("keeps a list mutable because another item can merge after a blank line", () => { + const result = splitStreamingMarkdown("- first\n\n"); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0].stable).toBe(false); + expect(result.stableOffset).toBe(0); + }); + + test("freezes fenced code only after its closing fence", () => { + expect(splitStreamingMarkdown("```ts\nconst x = 1").blocks[0].stable).toBe(false); + expect(splitStreamingMarkdown("```ts\nconst x = 1\n```\n").blocks[0].stable).toBe(true); + }); + + test("freezes a standalone display formula when its delimiter closes", () => { + expect(splitStreamingMarkdown("$$\\int_0^1 x dx").blocks[0].stable).toBe(false); + expect(splitStreamingMarkdown("$$\\int_0^1 x dx$$").blocks[0].stable).toBe(true); + expect(splitStreamingMarkdown("\\[\\frac{a}{b}\\]").blocks[0].stable).toBe(true); + }); + + test("marks every block stable after the stream completes", () => { + const result = splitStreamingMarkdown("paragraph without trailing newline", true); + expect(result.blocks[0].stable).toBe(true); + expect(result.stableOffset).toBe("paragraph without trailing newline".length); + }); +}); diff --git a/chat/lib/streaming-markdown.ts b/chat/lib/streaming-markdown.ts new file mode 100644 index 0000000..cfe6895 --- /dev/null +++ b/chat/lib/streaming-markdown.ts @@ -0,0 +1,77 @@ +import {marked} from "marked"; + +export type StreamingMarkdownBlock = { + source: string; + type: string; + start: number; + end: number; + stable: boolean; +}; + +export type StreamingMarkdownSplit = { + blocks: StreamingMarkdownBlock[]; + stableOffset: number; +}; + +type BlockToken = {type: string; raw: string}; + +function hasClosedFence(raw: string) { + const opening = raw.match(/^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/); + if (!opening) return false; + const marker = opening[2]; + const character = marker[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const closing = new RegExp(`(?:^|\\n) {0,3}${character}{${marker.length},}[ \\t]*(?:\\n|$)`); + return closing.test(raw.slice(opening[0].length)); +} + +function isClosedDisplayMath(raw: string) { + const value = raw.trim(); + return /^\$\$(?!\$)[\s\S]*?(? = []; + let offset = 0; + for (const token of tokens) { + const start = offset; + offset += token.raw.length; + records.push({...token, start, end: offset}); + } + + const semanticIndexes = records.flatMap((token, index) => token.type === "space" ? [] : [index]); + const lastSemanticIndex = semanticIndexes.at(-1) ?? -1; + let firstUnstableStart = source.length; + const blocks: StreamingMarkdownBlock[] = []; + + for (const index of semanticIndexes) { + const token = records[index]; + const stable = complete || index < lastSemanticIndex || isSelfClosing(token, records[index + 1]); + if (!stable && firstUnstableStart === source.length) firstUnstableStart = token.start; + if (token.type === "def") continue; + blocks.push({ + source: token.raw, + type: token.type, + start: token.start, + end: token.end, + stable + }); + } + + if (complete || firstUnstableStart === source.length) return {blocks, stableOffset: source.length}; + return {blocks, stableOffset: firstUnstableStart}; +} diff --git a/chat/package.json b/chat/package.json index ed0afec..eebe024 100644 --- a/chat/package.json +++ b/chat/package.json @@ -17,12 +17,14 @@ "@ai-sdk/openai-compatible": "3.0.28", "ai": "7.0.58", "dompurify": "3.2.6", + "fflate": "0.8.2", "marked": "15.0.12", "node-fetch": "3.3.2", "proxy-agent": "8.0.2" }, "devDependencies": { "@types/bun": "1.3.5", + "mathjax": "4.1.3", "typescript": "5.9.3" } } diff --git a/chat/proxy-bridge.mjs b/chat/proxy-bridge.mjs index d9d6be8..290aa37 100644 --- a/chat/proxy-bridge.mjs +++ b/chat/proxy-bridge.mjs @@ -1,4 +1,5 @@ import {mkdirSync, rmSync} from "node:fs"; +import http from "node:http"; import net from "node:net"; const mode = process.env.BRIDGE_MODE || "network"; @@ -6,35 +7,68 @@ const socketPath = process.env.BRIDGE_SOCKET || "/run/provider-proxy/upstream.so 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); +const upstreamHttpHost = process.env.UPSTREAM_HTTP_HOST || ""; 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(); +function createTcpServer() { + return 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 close = () => { - client.destroy(); - upstream.destroy(); - }; - client.on("error", close); - upstream.on("error", close); -}); +} + +function createHttpServer() { + return http.createServer((request, response) => { + const upstream = http.request({ + socketPath, + method: request.method, + path: request.url, + headers: {...request.headers, host: upstreamHttpHost} + }, (upstreamResponse) => { + response.writeHead( + upstreamResponse.statusCode || 502, + upstreamResponse.statusMessage, + upstreamResponse.headers + ); + upstreamResponse.pipe(response); + }); + + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502); + response.end(); + }); + request.on("aborted", () => upstream.destroy()); + request.pipe(upstream); + }); +} + +const server = mode === "network" && upstreamHttpHost + ? createHttpServer() + : createTcpServer(); 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}`); + const protocol = upstreamHttpHost ? `http host=${upstreamHttpHost}` : "tcp"; + console.log(`provider proxy ${mode} bridge ${target} -> ${upstream} (${protocol})`); }); function shutdown() { diff --git a/chat/public/manifest.webmanifest b/chat/public/manifest.webmanifest index 69fb5c1..51bfbd2 100644 --- a/chat/public/manifest.webmanifest +++ b/chat/public/manifest.webmanifest @@ -1,18 +1,20 @@ { + "id": "/chat/", "name": "Xiteng Chat", "short_name": "Chat", - "description": "使用个人 Key Vault 凭据的轻量 AI 对话界面", - "start_url": "/", - "scope": "/", + "description": "本地优先、可选登录同步的轻量 AI 对话界面", + "start_url": "/chat/", + "scope": "/chat/", "display": "standalone", + "launch_handler": {"client_mode": "navigate-existing"}, "background_color": "#f4f4f0", "theme_color": "#171717", "orientation": "any", "lang": "zh-CN", "categories": ["productivity", "utilities"], "icons": [ - {"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png"}, - {"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png"}, + {"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, + {"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"}, {"src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} ] } diff --git a/chat/public/sw.js b/chat/public/sw.js index 2aedee1..d414e35 100644 --- a/chat/public/sw.js +++ b/chat/public/sw.js @@ -1,8 +1,11 @@ -const cacheName = "xiteng-chat-shell-v4"; +const cachePrefix = "xiteng-chat-"; +const cacheName = `${cachePrefix}shell-v8`; +const basePath = "/chat"; const shellAssets = [ - "/", - "/manifest.webmanifest", + `${basePath}/`, + `${basePath}/manifest.webmanifest`, "/favicon.svg", + "/icons/favicon-32.png", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/icon-maskable-512.png", @@ -22,7 +25,7 @@ self.addEventListener("install", (event) => { self.addEventListener("activate", (event) => { event.waitUntil(caches.keys() - .then((keys) => Promise.all(keys.filter((key) => key !== cacheName).map((key) => caches.delete(key)))) + .then((keys) => Promise.all(keys.filter((key) => key.startsWith(cachePrefix) && key !== cacheName).map((key) => caches.delete(key)))) .then(() => self.clients.claim())); }); @@ -30,19 +33,25 @@ 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 (url.origin !== self.location.origin || url.pathname.startsWith(`${basePath}/api/`) || url.pathname.startsWith("/outpost.goauthentik.io/")) return; if (request.mode === "navigate") { event.respondWith(fetch(request).then((response) => { - if (response.ok) caches.open(cacheName).then((cache) => cache.put("/", response.clone())); + if (response.ok) { + const cacheable = response.clone(); + void caches.open(cacheName).then((cache) => cache.put(`${basePath}/`, cacheable)).catch(() => {}); + } return response; - }).catch(async () => (await caches.match("/")) || Response.error())); + }).catch(async () => (await caches.match(`${basePath}/`)) || Response.error())); return; } if (shellAssets.includes(url.pathname) || /\.(?:js|css|png|svg|ico|webmanifest)$/.test(url.pathname)) { event.respondWith(caches.match(request).then((cached) => cached || fetch(request).then((response) => { - if (response.ok) caches.open(cacheName).then((cache) => cache.put(request, response.clone())); + if (response.ok) { + const cacheable = response.clone(); + void caches.open(cacheName).then((cache) => cache.put(request, cacheable)).catch(() => {}); + } return response; }))); } diff --git a/chat/src/client.ts b/chat/src/client.ts index 08d758b..d0e6b50 100644 --- a/chat/src/client.ts +++ b/chat/src/client.ts @@ -1,24 +1,32 @@ import DOMPurify from "dompurify"; +import {unzip} from "fflate"; import {marked} from "marked"; import {applyBrowserProviderSettings} from "../lib/browser-provider-settings"; import {createBrowserProviderFetch} from "../lib/browser-provider-fetch"; import { + commitConversationMessage, createConversationHistory, deleteConversationHistory, - flushPendingConversationChanges, getConversationHistory, listConversationHistory, - saveConversationHistory, - synchronizeOfflineConversationHistory, + moveConversationHead, + synchronizeConversationRepository, updateConversationHistory } from "../lib/conversation-client"; import {conversationHash, conversationIdFromHash} from "../lib/conversation-hash"; -import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "../lib/conversation-types"; +import {conversationTitlePrompt, normalizeGeneratedConversationTitle, untitledConversationLabel} from "../lib/conversation-title"; +import type {Conversation, ConversationSummary, MessageCompletion, ResponseMetadata, StoredChatMessage, WorkingItem} from "../lib/conversation-types"; import {defaultGenerationSettings, type GenerationSettings} from "../lib/generation-settings"; import { activateOfflineProfile, + cachedLastFetchAt, cacheChatConfig, - loadCachedChatConfig + listCachedMessages, + listWorkingItems, + loadCachedChatConfig, + mergeOfflineProfiles, + removeWorkingItem, + saveWorkingItem } from "../lib/offline-history"; import { deleteLocalCredential, @@ -30,13 +38,39 @@ import { import type {ChatProfile} from "../lib/profile-types"; import type {ProviderDefinition, ProviderModel, ProviderSecret} from "../lib/provider-types"; import {responseMetadata} from "../lib/response-metadata"; +import {createMessageObject} from "../lib/message-object"; +import { + conversationTransferDocument, + parseSessionTransfer, + serializeSessionTransfer, + serializeXitengArchive, + type SessionTransferFormat, + type TransferDocument, + type TransferNode +} from "../lib/session-transfer"; +import {protectMath, restoreMath} from "../lib/math-markdown"; +import {IncrementalMarkdownCache} from "../lib/incremental-markdown-cache"; +import {mergeMessageGraph, messageChildrenInGraph, messagePathInGraph, newestBranchTipInGraph, rootEditAlternativesInGraph} from "../lib/message-graph"; +import {compactModelName} from "../lib/model-display"; +import {applyImportTitleTemplate, importFileStem, importSourceFolder} from "../lib/import-title-template"; +import {shouldOpenFullscreenEditor} from "../lib/fullscreen-editor"; type ChatProvider = ProviderDefinition & {models: ProviderModel[]; modelDiscoveryError?: string}; type ChatConfig = {providers: ChatProvider[]; profile: ChatProfile}; type ServerChatConfig = ChatConfig & {identityKey: string}; +type PublicChatConfig = {providers: ProviderDefinition[]}; type CachedChatBootstrap = {config: ChatConfig; frontendProviders: ChatProvider[]}; type StreamEvent = {type: string; text?: string; error?: string; metadata?: ResponseMetadata}; +type StreamRequestContext = {provider: ChatProvider; model: string; conversationId: string; generationSettings: GenerationSettings}; type HashNavigationMode = "push" | "replace" | "none"; +type MathJaxRuntime = { + startup?: {promise?: Promise}; + typesetPromise?: (elements?: Element[]) => Promise; + typesetClear?: (elements?: Element[]) => void; +}; +const basePath = "/chat"; +const appUrl = (pathname: string) => `${basePath}${pathname}`; +const mathJaxAssetPath = `${basePath}/assets/mathjax/4.1.3`; const rootElement = document.querySelector("#app"); if (!rootElement) throw new Error("Application root is missing"); @@ -59,11 +93,33 @@ const state = { modelQuery: "", streaming: false, streamController: null as AbortController | null, + workingItems: [] as WorkingItem[], + activeDraftId: "", + historyTree: window.localStorage.getItem("xiteng-chat-history-tree") === "1", + advancedActions: window.localStorage.getItem("xiteng-chat-advanced-actions") === "1", + identityKey: "", + authenticated: false, + syncing: false, + syncRequested: false, + initialFetchComplete: false, + lastFetchAt: "", + syncError: "", renderFrame: 0, - settingsTimer: 0 + settingsTimer: 0, + syncTimer: 0, + workingSaveTimers: new Map(), + messageGraph: [] as StoredChatMessage[], + previewHeadId: "", + queuedDraftId: "", + importPanelOpen: false, + importing: false, + importStatus: "", + importTitleTemplate: window.localStorage.getItem("xiteng-chat-import-title-template") || "{title}", + composerFullscreen: false }; +const titleGenerationConversationIds = new Set(); -const icons: Record = { +const icons = Object.fromEntries(Object.entries({ history: '', plus: '', close: '', @@ -77,9 +133,17 @@ const icons: Record = { stop: '', copy: '', retry: '', + reply: '', + edit: '', + tree: '', + list: '', scroll: '', - offline: '' -}; + offline: '', + transfer: '', + upload: '', + expand: '', + collapse: '' +}).map(([name, markup]) => [name, markup.replace("; function uuid() { if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); const bytes = crypto.getRandomValues(new Uint8Array(16)); @@ -97,8 +161,526 @@ function messagePartText(message: StoredChatMessage, type: "text" | "reasoning") return message.parts.filter((part) => part.type === type && typeof part.text === "string").map((part) => String(part.text)).join(""); } +function knownMessageMap() { + return mergeMessageGraph(state.messageGraph, state.conversation?.messages || []); +} + +function conversationGraphObjects(conversation: Conversation) { + if (!conversation.headMessageId) return []; + const messages = [...knownMessageMap().values()]; + const adjacent = new Map>(); + const connect = (left: string, right: string) => { + if (!adjacent.has(left)) adjacent.set(left, new Set()); + if (!adjacent.has(right)) adjacent.set(right, new Set()); + adjacent.get(left)!.add(right); + adjacent.get(right)!.add(left); + }; + for (const message of messages) { + if (message.parentMessageId) connect(message.id, message.parentMessageId); + if ("sourceMessageId" in message.origin && message.origin.sourceMessageId) connect(message.id, message.origin.sourceMessageId); + } + const selected = new Set(); + const pending = [conversation.headMessageId]; + while (pending.length) { + const id = pending.pop()!; + if (selected.has(id)) continue; + selected.add(id); + for (const neighbor of adjacent.get(id) || []) pending.push(neighbor); + } + return messages.filter((message) => selected.has(message.id)); +} + +function downloadText(filename: string, text: string, type = "application/x-ndjson") { + const url = URL.createObjectURL(new Blob([text], {type})); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function exportFilename(name: string, suffix: string) { + const safe = (String(name || "").trim() || "xiteng-chat").replace(/[\\/:*?"<>|\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "xiteng-chat"; + return `${safe}.${suffix}`; +} + +async function exportSessions(format: SessionTransferFormat) { + if (format === "xiteng") { + const conversations = await Promise.all(state.conversations.map((summary) => getConversationHistory(summary.id))); + const [objects, workingItems] = await Promise.all([listCachedMessages(), listWorkingItems()]); + downloadText(exportFilename("xiteng-chat-backup", "xiteng-chat.json"), serializeXitengArchive(conversations, objects, workingItems), "application/json"); + return; + } + if (!state.conversation) throw new Error("当前没有可导出的会话"); + const document = conversationTransferDocument(state.conversation, conversationGraphObjects(state.conversation)); + downloadText(exportFilename(state.conversation.name, `${format}.jsonl`), serializeSessionTransfer(document, format)); +} + +async function materializeTransferNodes(document: TransferDocument) { + const mappedIds = new Map(); + const stored = new Map(); + const pending = new Map(document.nodes.map((node) => [node.sourceId, node])); + while (pending.size) { + let progressed = false; + for (const [sourceId, node] of [...pending]) { + if (node.parentSourceId && pending.has(node.parentSourceId) && !mappedIds.has(node.parentSourceId)) continue; + const parentMessageId = node.parentSourceId ? mappedIds.get(node.parentSourceId) || null : null; + const message = await createMessageObject({ + parentMessageId, + role: node.role, + parts: node.parts, + origin: node.origin || (node.role === "user" ? {type: "user", sourceMessageId: sourceId} : {type: "legacy"}), + completion: node.completion || {status: "complete"}, + createdAt: node.createdAt, + completedAt: node.completedAt, + ...(node.metadata ? {metadata: node.metadata} : {}) + }, state.identityKey); + mappedIds.set(sourceId, message.id); + stored.set(message.id, message); + pending.delete(sourceId); + progressed = true; + } + if (!progressed) throw new Error("导入文件的消息父节点形成循环"); + } + return {mappedIds, messages: [...stored.values()]}; +} + +function transferSessionSourceIds(document: TransferDocument, headSourceId: string | null) { + if (!headSourceId) return new Set(); + const adjacent = new Map>(); + const connect = (left: string, right: string) => { + if (!adjacent.has(left)) adjacent.set(left, new Set()); + if (!adjacent.has(right)) adjacent.set(right, new Set()); + adjacent.get(left)!.add(right); + adjacent.get(right)!.add(left); + }; + for (const node of document.nodes) if (node.parentSourceId) connect(node.sourceId, node.parentSourceId); + const selected = new Set(); + const pending = [headSourceId]; + while (pending.length) { + const id = pending.pop()!; + if (selected.has(id)) continue; + selected.add(id); + for (const neighbor of adjacent.get(id) || []) pending.push(neighbor); + } + return selected; +} + +function uniqueImportedName(source: string, reserved: Set) { + const base = String(source || "").trim() || "导入的会话"; + if (!reserved.has(base)) { + reserved.add(base); + return base; + } + let index = 2; + while (reserved.has(`${base} (导入 ${index})`)) index += 1; + const name = `${base} (导入 ${index})`; + reserved.add(name); + return name; +} + +async function importTransferDocument(document: TransferDocument, reservedNames: Set, source: string, firstIndex: number) { + if (!document.sessions.length) throw new Error("导入文件中没有会话"); + const {mappedIds, messages} = await materializeTransferNodes(document); + const messagesById = new Map(messages.map((message) => [message.id, message])); + const conversationIds = new Map(); + const imported: Conversation[] = []; + for (const [sessionIndex, session] of document.sessions.entries()) { + const headMessageId = session.headSourceId ? mappedIds.get(session.headSourceId) || null : null; + const sessionMessages = [...transferSessionSourceIds(document, session.headSourceId)] + .map((sourceId) => mappedIds.get(sourceId)) + .filter((id): id is string => Boolean(id)) + .map((id) => messagesById.get(id)) + .filter((message): message is StoredChatMessage => Boolean(message)); + const sourceTitle = session.name.trim() || "导入的会话"; + const importedTitle = applyImportTitleTemplate(state.importTitleTemplate, { + title: sourceTitle, + format: document.format, + file: importFileStem(source.replaceAll("\\", "/").split("/").at(-1)?.trim() || source), + folder: importSourceFolder(source), + date: session.createdAt.slice(0, 10), + model: session.model, + provider: session.providerId, + index: firstIndex + sessionIndex + }); + const created = await createConversationHistory( + session.providerId, + session.model, + session.generationSettings, + uniqueImportedName(importedTitle, reservedNames), + headMessageId, + sessionMessages + ); + conversationIds.set(session.sourceId, created.id); + imported.push(await getConversationHistory(created.id)); + } + for (const item of document.workingItems || []) { + const conversationId = conversationIds.get(item.conversationId); + if (!conversationId) continue; + await saveWorkingItem({ + ...item, + id: uuid(), + conversationId, + observedHeadId: item.observedHeadId ? mappedIds.get(item.observedHeadId) || null : null, + ...(item.editSourceMessageId ? {editSourceMessageId: mappedIds.get(item.editSourceMessageId)} : {}) + }); + } + return imported; +} + +function sessionFileCandidate(name: string) { + const lower = name.toLowerCase(); + return lower.endsWith(".json") || lower.endsWith(".jsonl"); +} + +function archiveFileCandidate(name: string) { + return name.toLowerCase().endsWith(".zip"); +} + +type ImportSourceFile = {file: File; source: string}; + +async function filesFromZip(archive: File) { + const archiveBytes = new Uint8Array(await archive.arrayBuffer()); + let acceptedBytes = 0; + let acceptedFiles = 0; + const entries = await new Promise>((resolve, reject) => { + unzip(archiveBytes, { + filter(entry) { + if (!sessionFileCandidate(entry.name) || entry.originalSize > 64 * 1024 * 1024) return false; + if (acceptedFiles >= 2_000 || acceptedBytes + entry.originalSize > 512 * 1024 * 1024) return false; + acceptedFiles += 1; + acceptedBytes += entry.originalSize; + return true; + } + }, (error, result) => error ? reject(error) : resolve(result)); + }); + return Object.entries(entries).map(([name, bytes]) => { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return { + file: new File([copy.buffer], name, {type: name.toLowerCase().endsWith(".jsonl") ? "application/x-ndjson" : "application/json"}), + source: `${archive.name} / ${name}` + }; + }); +} + +async function expandImportFiles(files: Iterable) { + const expanded: ImportSourceFile[] = []; + for (const file of files) { + if (sessionFileCandidate(file.name)) expanded.push({file, source: file.webkitRelativePath || file.name}); + else if (archiveFileCandidate(file.name)) expanded.push(...await filesFromZip(file)); + } + return expanded; +} + +async function filesFromDirectory(handle: FileSystemDirectoryHandle) { + const files: ImportSourceFile[] = []; + let visited = 0; + const walk = async (directory: FileSystemDirectoryHandle, prefix: string): Promise => { + const entries = (directory as FileSystemDirectoryHandle & {values(): AsyncIterableIterator}).values(); + for await (const entry of entries) { + visited += 1; + if (visited > 25_000) throw new Error("文件夹内容过多;请直接选择 sessions 或 projects 目录,而不是整个主目录"); + const path = `${prefix}/${entry.name}`; + if (entry.kind === "directory") { + if (["node_modules", ".git", "cache", "logs"].includes(entry.name.toLowerCase())) continue; + await walk(entry, path); + } else if (sessionFileCandidate(entry.name)) { + if (files.length >= 2_000) throw new Error("检测到超过 2000 个会话文件;请分批选择更小的目录"); + files.push({file: await entry.getFile(), source: path}); + } + } + }; + await walk(handle, handle.name); + return files; +} + +async function importSessionFiles(files: Iterable, sourceLabel: string) { + const candidates = [...files]; + if (!candidates.length) throw new Error("没有找到可导入的 .json 或 .jsonl 会话文件"); + applyImportTitleTemplate(state.importTitleTemplate, {title: "标题", format: "codex", file: "rollout", folder: "sessions", date: "2026-08-13", model: "model", provider: "provider", index: 1}); + state.importing = true; + state.importStatus = `正在扫描 ${sourceLabel} 中的 ${candidates.length} 个候选文件…`; + renderApp(); + let imported = 0; + const formats = new Set(); + const failures: string[] = []; + const reservedNames = new Set(state.conversations.map((item) => item.name)); + let firstConversationId = ""; + for (const [index, candidate] of candidates.entries()) { + state.importStatus = `正在导入 ${index + 1} / ${candidates.length}:${candidate.source}`; + updateImportStatus(); + try { + const document = parseSessionTransfer(await candidate.file.text(), candidate.file.name); + formats.add(document.format); + const conversations = await importTransferDocument(document, reservedNames, candidate.source, imported + 1); + imported += conversations.length; + firstConversationId ||= conversations[0]?.id || ""; + } catch (error) { + failures.push(`${candidate.source}:${error instanceof Error ? error.message : "无法解析"}`); + } + } + try { + state.conversations = await listConversationHistory(); + state.messageGraph = await listCachedMessages(); + if (firstConversationId) await selectConversation(firstConversationId); + if (imported) scheduleRepositorySync(); + const formatLabel = formats.size ? ` · ${[...formats].join(" / ")}` : ""; + state.importStatus = `已导入 ${imported} 个会话${formatLabel}${failures.length ? `;跳过 ${failures.length} 个无法识别的文件` : ""}`; + if (failures.length) state.importStatus += `\n${failures.slice(0, 3).join("\n")}${failures.length > 3 ? `\n另有 ${failures.length - 3} 个…` : ""}`; + } catch (error) { + state.importStatus = `导入在保存阶段中断:${error instanceof Error ? error.message : "未知错误"}`; + throw error; + } finally { + state.importing = false; + renderApp(); + } +} + +function updateImportStatus() { + const status = root.querySelector(".session-import-status"); + if (status) status.textContent = state.importStatus; +} + +async function chooseImportDirectory() { + const picker = (window as Window & {showDirectoryPicker?: (options?: {mode?: "read"}) => Promise}).showDirectoryPicker; + if (!picker) { + root.querySelector('[data-action="session-directory"]')?.click(); + return; + } + try { + const handle = await picker({mode: "read"}); + state.importing = true; + state.importStatus = `正在只读扫描文件夹 ${handle.name}…`; + renderApp(); + const files = await filesFromDirectory(handle); + await importSessionFiles(files, `文件夹 ${handle.name}`); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + state.importing = false; + state.importStatus = `文件夹扫描失败:${error instanceof Error ? error.message : "未知错误"}`; + renderApp(); + throw error; + } +} + +function messagePathTo(headMessageId: string | null) { + return messagePathInGraph(knownMessageMap(), headMessageId); +} + +function displayedMessages() { + if (!state.previewHeadId) return state.conversation?.messages || []; + return messagePathTo(state.previewHeadId); +} + +function messageChildren(parentMessageId: string | null) { + return messageChildrenInGraph(knownMessageMap(), parentMessageId); +} + +function newestBranchTip(startId: string) { + const currentIds = new Set((state.conversation?.messages || []).map((message) => message.id)); + return newestBranchTipInGraph(knownMessageMap(), startId, currentIds, state.conversation?.headMessageId || null); +} + function markdown(value: string) { - return DOMPurify.sanitize(marked.parse(value, {async: false, gfm: true, breaks: false}) as string); + const {source, fragments} = protectMath(value); + const html = marked.parse(source, {async: false, gfm: true, breaks: false}) as string; + return { + html: DOMPurify.sanitize(restoreMath(html, fragments)), + hasMath: fragments.length > 0 + }; +} + +function markdownRenderKey(value: string) { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `${value.length}-${(hash >>> 0).toString(36)}`; +} + +type RenderedMarkdownBlock = { + id: string; + index: number; + type: string; + source: string; + html: string; + renderKey: string; + hasMath: boolean; + stable: boolean; +}; + +const streamingMarkdownCaches = new IncrementalMarkdownCache>(); +const finishingMarkdownMessages = new Set(); +const markdownRenderMetrics = { + lexMs: 0, + parseMs: 0, + blocksParsed: 0, + blocksReused: 0, + domBlocksUpdated: 0, + mathTypesets: 0 +}; +(window as typeof window & {__xitengChatRenderMetrics?: typeof markdownRenderMetrics}).__xitengChatRenderMetrics = markdownRenderMetrics; + +function renderMarkdownBlock(source: string, messageId: string, index: number, type: string, stable: boolean): RenderedMarkdownBlock { + const startedAt = performance.now(); + const rendered = markdown(source); + markdownRenderMetrics.parseMs += performance.now() - startedAt; + markdownRenderMetrics.blocksParsed += 1; + return { + id: `${messageId}:markdown:${index}`, + index, + type, + source, + html: rendered.html, + renderKey: markdownRenderKey(source), + hasMath: rendered.hasMath, + stable + }; +} + +function markdownBlocks(value: string, messageId: string, complete: boolean) { + if (complete && !streamingMarkdownCaches.has(messageId)) { + return [renderMarkdownBlock(value, messageId, 0, "document", true)]; + } + + const result = streamingMarkdownCaches.render( + messageId, + value, + (source, type, index, stable) => renderMarkdownBlock(source, messageId, index, type, stable), + (durationMs) => { markdownRenderMetrics.lexMs += durationMs; }, + complete + ); + markdownRenderMetrics.blocksReused += result.reused; + return result.blocks; +} + +function markdownBlockMarkup(block: RenderedMarkdownBlock) { + return `
${block.html}
`; +} + +function isStreamingAssistant(message: StoredChatMessage, index: number) { + return state.streaming + && state.conversation?.messages.at(-1)?.id === message.id; +} + +function renderMarkdownContainer(value: string, messageId: string, complete: boolean) { + const blocks = markdownBlocks(value, messageId, complete); + return `
${blocks.map(markdownBlockMarkup).join("")}
`; +} + +function mathJaxRuntime() { + return (window as typeof window & {MathJax?: MathJaxRuntime}).MathJax; +} + +let mathJaxLoad: Promise | null = null; +let mathTypesetChain = Promise.resolve(); +let mathTypesetFrame = 0; +const pendingMathElements = new Map(); + +function ensureMathJax() { + const active = mathJaxRuntime(); + if (active?.typesetPromise) return Promise.resolve(active); + if (mathJaxLoad) return mathJaxLoad; + + (window as typeof window & {MathJax?: unknown}).MathJax = { + loader: {paths: {fonts: `${mathJaxAssetPath}/fonts`}}, + output: {font: "mathjax-newcm"}, + tex: { + inlineMath: {"[+]": [["$", "$"]]}, + processEscapes: true + }, + options: { + enableSpeech: false, + enableBraille: false, + enableExplorer: false, + menuOptions: {settings: {speech: false, braille: false}} + }, + svg: {fontCache: "local"}, + startup: {typeset: false} + }; + + mathJaxLoad = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = `${mathJaxAssetPath}/tex-svg-nofont.js`; + script.async = true; + script.dataset.mathjax = "true"; + script.addEventListener("error", () => reject(new Error("MathJax 加载失败")), {once: true}); + script.addEventListener("load", async () => { + try { + const runtime = mathJaxRuntime(); + await runtime?.startup?.promise; + if (!runtime?.typesetPromise) throw new Error("MathJax 初始化失败"); + resolve(runtime); + } catch (error) { + reject(error); + } + }, {once: true}); + document.head.appendChild(script); + }).catch((error) => { + mathJaxLoad = null; + throw error; + }); + return mathJaxLoad; +} + +function clearMathTypesetting(element: Element) { + mathJaxRuntime()?.typesetClear?.([element]); +} + +function scheduleMathTypesetting(element: Element) { + const candidates = element.matches('.math-fragment:not([data-math-rendered="1"])') + ? [element as HTMLElement] + : Array.from(element.querySelectorAll('.math-fragment:not([data-math-rendered="1"])')); + for (const candidate of candidates) { + pendingMathElements.set(candidate, candidate.dataset.mathKey || ""); + candidate.dataset.mathPending = "1"; + } + if (!candidates.length || mathTypesetFrame) return; + mathTypesetFrame = window.requestAnimationFrame(() => { + mathTypesetFrame = 0; + const batch = [...pendingMathElements.entries()]; + pendingMathElements.clear(); + mathTypesetChain = mathTypesetChain.then(async () => { + const current = batch.filter(([candidate, mathKey]) => candidate.isConnected && candidate.dataset.mathKey === mathKey); + if (!current.length) return; + const runtime = await ensureMathJax(); + const stillCurrent = current.filter(([candidate, mathKey]) => candidate.isConnected && candidate.dataset.mathKey === mathKey); + if (!stillCurrent.length) return; + const viewport = root.querySelector("#thread-viewport"); + const wasAtBottom = isViewportAtBottom(viewport); + const previousScrollTop = viewport?.scrollTop || 0; + await runtime.typesetPromise!(stillCurrent.map(([candidate]) => candidate)); + markdownRenderMetrics.mathTypesets += stillCurrent.length; + for (const [candidate, mathKey] of stillCurrent) { + if (candidate.isConnected && candidate.dataset.mathKey === mathKey) { + candidate.dataset.mathRendered = "1"; + candidate.dataset.mathTypesetCount = String(Number(candidate.dataset.mathTypesetCount || "0") + 1); + const block = candidate.closest(".markdown-block"); + if (block) block.dataset.mathTypesetCount = String(Number(block.dataset.mathTypesetCount || "0") + 1); + delete candidate.dataset.mathPending; + } + } + if (wasAtBottom) scrollBottom(); + else if (viewport) viewport.scrollTop = previousScrollTop; + for (const [candidate, mathKey] of stillCurrent) { + if (candidate.isConnected && candidate.dataset.mathKey !== mathKey) scheduleMathTypesetting(candidate); + } + }).catch((error) => { + for (const [candidate] of batch) delete candidate.dataset.mathPending; + console.error("MathJax typesetting failed", error); + }); + }); +} + +function estimateFrontendOutputTokens(value: string): number { + const trimmed = value.trim(); + if (!trimmed) return 0; + const withoutSpaces = trimmed.replace(/\s+/g, ""); + const chineseChars = (withoutSpaces.match(/\p{Script=Han}/gu) || []).length; + const nonChineseChars = withoutSpaces.length - chineseChars; + return Math.max(0, Math.round(chineseChars + nonChineseChars / 4)); } function provider() { @@ -147,8 +729,9 @@ function avatarPlaceholder(profile: ChatProfile) { async function updateAvatar() { const image = root.querySelector(".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 email = String(profile?.email || "").trim().toLowerCase(); + if (!image || !email) return; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(email)); const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); for (const source of [`https://www.gravatar.com/avatar/${hash}?d=404&s=256`, `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256`]) { const loaded = await new Promise((resolve) => { @@ -169,6 +752,8 @@ async function updateAvatar() { function renderModelPicker() { const active = provider(); if (!active || !state.config) return ""; + const activeModelName = compactModelName(active.models.find((model) => model.id === state.model)?.name || state.model); + const settings = state.generationSettings; 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)); @@ -178,59 +763,278 @@ function renderModelPicker() { const items = matches.filter((choice) => choice.provider.id === item.id); return items.length ? `

${escapeHtml(item.name)}

${items.map(option).join("")}
` : ""; }).join(""); - return `
${escapeHtml(state.providerId)}/${escapeHtml(state.model)}${icons.down}
${recent.length ? `

${icons.clock}最近使用

${recent.map(option).join("")}
` : ""}${groups || '

没有匹配的模型

'}
`; + const providerSettings = renderProviderSettings(); + const generationSettings = `
生成参数随当前会话保存
界面仅保存在当前浏览器
显示“提交后让 AI 回答”和编辑助手回答。
`; + return `
${escapeHtml(activeModelName)}${icons.down}
${recent.length ? `

${icons.clock}最近使用

${recent.map(option).join("")}
` : ""}${groups || '

没有匹配的模型

'}${providerSettings}
${generationSettings}
`; } -function renderLocalMenu() { +function renderProviderSettings() { if (!state.frontendProviders.length) return ""; - return `
${icons.more}
Frontend Provider 设置${state.frontendProviders.map((item) => { + return `
Provider 连接找不到模型时,在这里配置浏览器直连 Provider
${state.frontendProviders.map((item) => { const configured = state.localCredentials.some((credential) => credential.providerId === item.id); return `
${escapeHtml(item.name)}${escapeHtml(item.auth.type === "none" ? item.connection.baseUrl : item.id)}${item.modelDiscoveryError ? `${escapeHtml(item.modelDiscoveryError)}` : ""}
${configured ? `` : ""}${item.auth.type === "none" ? `` : ""}
`; - }).join("")}
`; + }).join("")}`; } -function renderGenerationMenu() { - const settings = state.generationSettings; - return `
${icons.settings}
生成参数
`; +function historyItem(item: ConversationSummary, label = item.name, depth = 0) { + const displayLabel = String(label || "").trim() || untitledConversationLabel; + return `
`; +} + +type HistoryTreeNode = {segment: string; path: string; conversations: ConversationSummary[]; children: Map}; + +function renderHistoryItems() { + if (!state.historyTree) return state.conversations.map((item) => historyItem(item)).join(""); + const rootNode: HistoryTreeNode = {segment: "", path: "", conversations: [], children: new Map()}; + for (const conversation of state.conversations) { + const segments = String(conversation.name || "").split("/").filter(Boolean); + let node = rootNode; + segments.forEach((segment, index) => { + const path = segments.slice(0, index + 1).join("/"); + let child = node.children.get(segment); + if (!child) { + child = {segment, path, conversations: [], children: new Map()}; + node.children.set(segment, child); + } + node = child; + }); + node.conversations.push(conversation); + } + const renderNode = (node: HistoryTreeNode, depth: number): string => { + const children = [...node.children.values()].sort((left, right) => left.segment.localeCompare(right.segment)); + const hasChildren = children.length > 0; + const rows = node.conversations.map((item) => historyItem(item, node.segment || item.name, depth)).join(""); + const folder = hasChildren && node.segment + ? `
${escapeHtml(node.segment)}
` + : ""; + return `${folder}${rows}${children.map((child) => renderNode(child, node.segment ? depth + 1 : depth)).join("")}`; + }; + return renderNode(rootNode, 0); +} + +function importTitleTemplatePreview() { + try { + return { + text: `预览:${applyImportTitleTemplate(state.importTitleTemplate, {title: "修复登录问题", format: "codex", file: "rollout-123", folder: "sessions", date: "2026-08-13", model: "gpt-5.6-sol", provider: "openai", index: 1})}`, + error: false + }; + } catch (error) { + return {text: error instanceof Error ? error.message : "标题模板无效", error: true}; + } +} + +function renderImportPanel() { + if (!state.importPanelOpen) return ""; + const disabled = state.importing ? " disabled" : ""; + const preview = importTitleTemplatePreview(); + return ``; } function renderHistory() { - return ``; + const transferMenu = `
${icons.transfer}

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

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

`; + return `

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

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

今天想聊什么?

${escapeHtml(description)}

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

${note}

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

聊天服务暂时不可用

${escapeHtml(state.error)}

`; + root.innerHTML = `
!

聊天服务暂时不可用

${escapeHtml(state.error)}

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

BUILT-IN + CUSTOM

Provider Registry

-
只需填写 URL 与 API Key 即可先行探测;测试不会保存 Provider 或凭据。Backend Key 保存到 Key Vault;Frontend Key 必须跳转到 Chat 后保存在 chat.xiteng.site 的浏览器存储中。
+
只需填写 URL 与 API Key 即可先行探测;测试不会保存 Provider 或凭据。Backend Key 保存到 Key Vault;Frontend Key 必须跳转到 Chat 后保存在 xiteng.site/chat 的浏览器存储中。
diff --git a/site/account.js b/site/account.js index 715f274..4078823 100644 --- a/site/account.js +++ b/site/account.js @@ -485,7 +485,7 @@ function renderProvider(provider) { }))); if (provider.connection.type === "frontend") { actions.append(actionButton(provider.auth.type === "none" ? "在 Chat 中配置端点" : "在 Chat 中配置本地 Key(需 CORS / Relay)", () => { - window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(provider.id)}`; + window.location.href = `/chat/?configure=${encodeURIComponent(provider.id)}`; })); } if (!provider.builtin) { @@ -712,7 +712,7 @@ document.getElementById("account-provider-form").addEventListener("submit", asyn }); } } else if (connectionType === "frontend") { - window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(providerId)}`; + window.location.href = `/chat/?configure=${encodeURIComponent(providerId)}`; return; } await loadVault();