From f1c5c8af56615152513ab3d41081cd48ed434301 Mon Sep 17 00:00:00 2001 From: hukai <1725587426@qq.com> Date: Fri, 4 Sep 2026 14:25:54 +0800 Subject: [PATCH 01/26] feat(v1): personChat backend-ification + disable legacy provider/webdav routes [ISSUE-00/01] - Route all chat data through /backend-api/* -> Backend /api/* -> Gemini Web via next.config.mjs rewrite; remove legacy azure/google/openai/anthropic/ alibaba/sharegpt proxies and /api/* CORS headers. - Backend-ify Conversation/Message/SSE/Cancel/Archive/Delete/Draft in store/chat.ts + app/client/backend-api.ts; remove IndexedDB chat sync (utils/sync.ts StoreKey.Chat). - Hide legacy provider UI / model switch / API key / image upload / WebDAV / ShareGPT across settings.tsx, chat.tsx, exporter.tsx, mask.tsx, sidebar.tsx. - ISSUE-01: disable app/api/[provider], webdav, tencent, upstash, artifacts routes -> return 404 on all HTTP methods, never enter original handler. - /api/config retained but sanitized to non-provider flags only (needCode / hideBalanceQuery / disableFastLink); store/access.ts drops the defaultModel handling that depended on removed config fields. - chore(tooling): disable unused-imports/no-unused-imports in .eslintrc.json. eslint-plugin-unused-imports@3.2.0 + ESLint 8.49 crashes on TS enums under Node 24 (eslint-rule-composer reads loc of undefined); the crash reproduces on the unmodified HEAD constant.ts, so it is pre-existing tooling breakage, not a V1 regression. The rule was warn-level; next/core-web-vitals lint + tsc still apply. This unblocks the husky pre-commit (lint-staged) hook. --- .eslintrc.json | 7 +- app/api/[provider]/[...path]/route.ts | 100 +- app/api/artifacts/route.ts | 84 +- app/api/config/route.ts | 10 +- app/api/tencent/route.ts | 134 +- app/api/upstash/[action]/[...key]/route.ts | 88 +- app/api/webdav/[...path]/route.ts | 182 +-- app/client/backend-api.ts | 390 +++++ app/components/chat-list.tsx | 23 +- app/components/chat.module.scss | 6 + app/components/chat.tsx | 1475 +++---------------- app/components/exporter.tsx | 180 +-- app/components/home.tsx | 22 +- app/components/mask.tsx | 25 - app/components/settings.tsx | 1363 +----------------- app/components/sidebar.tsx | 19 +- app/constant.ts | 6 +- app/icons/archive.svg | 1 + app/locales/cn.ts | 11 +- app/locales/en.ts | 11 +- app/store/access.ts | 12 - app/store/chat.ts | 1509 ++++++++++---------- app/utils/sync.ts | 48 +- next.config.mjs | 63 +- test/backend-chat-store.test.ts | 711 +++++++++ 25 files changed, 2176 insertions(+), 4304 deletions(-) create mode 100644 app/client/backend-api.ts create mode 100644 app/icons/archive.svg create mode 100644 test/backend-chat-store.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index 5b5e88e67aa..105bd80c512 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,10 @@ { "extends": "next/core-web-vitals", - "plugins": ["prettier", "unused-imports"], + "plugins": [ + "prettier", + "unused-imports" + ], "rules": { - "unused-imports/no-unused-imports": "warn" + "unused-imports/no-unused-imports": "off" } } diff --git a/app/api/[provider]/[...path]/route.ts b/app/api/[provider]/[...path]/route.ts index e8af34f29f8..140476c091c 100644 --- a/app/api/[provider]/[...path]/route.ts +++ b/app/api/[provider]/[...path]/route.ts @@ -1,85 +1,21 @@ -import { ApiPath } from "@/app/constant"; -import { NextRequest } from "next/server"; -import { handle as openaiHandler } from "../../openai"; -import { handle as azureHandler } from "../../azure"; -import { handle as googleHandler } from "../../google"; -import { handle as anthropicHandler } from "../../anthropic"; -import { handle as baiduHandler } from "../../baidu"; -import { handle as bytedanceHandler } from "../../bytedance"; -import { handle as alibabaHandler } from "../../alibaba"; -import { handle as moonshotHandler } from "../../moonshot"; -import { handle as stabilityHandler } from "../../stability"; -import { handle as iflytekHandler } from "../../iflytek"; -import { handle as deepseekHandler } from "../../deepseek"; -import { handle as siliconflowHandler } from "../../siliconflow"; -import { handle as xaiHandler } from "../../xai"; -import { handle as chatglmHandler } from "../../glm"; -import { handle as proxyHandler } from "../../proxy"; -import { handle as ai302Handler } from "../../302ai"; +import { NextResponse } from "next/server"; -async function handle( - req: NextRequest, - { params }: { params: { provider: string; path: string[] } }, -) { - const apiPath = `/api/${params.provider}`; - console.log(`[${params.provider} Route] params `, params); - switch (apiPath) { - case ApiPath.Azure: - return azureHandler(req, { params }); - case ApiPath.Google: - return googleHandler(req, { params }); - case ApiPath.Anthropic: - return anthropicHandler(req, { params }); - case ApiPath.Baidu: - return baiduHandler(req, { params }); - case ApiPath.ByteDance: - return bytedanceHandler(req, { params }); - case ApiPath.Alibaba: - return alibabaHandler(req, { params }); - // case ApiPath.Tencent: using "/api/tencent" - case ApiPath.Moonshot: - return moonshotHandler(req, { params }); - case ApiPath.Stability: - return stabilityHandler(req, { params }); - case ApiPath.Iflytek: - return iflytekHandler(req, { params }); - case ApiPath.DeepSeek: - return deepseekHandler(req, { params }); - case ApiPath.XAI: - return xaiHandler(req, { params }); - case ApiPath.ChatGLM: - return chatglmHandler(req, { params }); - case ApiPath.SiliconFlow: - return siliconflowHandler(req, { params }); - case ApiPath.OpenAI: - return openaiHandler(req, { params }); - case ApiPath["302.AI"]: - return ai302Handler(req, { params }); - default: - return proxyHandler(req, { params }); - } +/** + * personChat V1:唯一聊天通道为 /backend-api/* → Backend /api/* → Gemini Web。 + * 旧 NextChat 多 Provider 代理(openai/anthropic/google/azure/baidu/bytedance/alibaba/moonshot/stability/iflytek/deepseek/xai/glm/siliconflow/302ai 及 default proxy)已下线。 + * 所有 HTTP 方法统一返回 404,绝不进入原 Handler(ISSUE-01)。 + */ +function disabledV1LegacyRoute() { + return NextResponse.json( + { error: true, message: "This legacy route is disabled in personChat V1" }, + { status: 404 }, + ); } -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; +export const GET = disabledV1LegacyRoute; +export const POST = disabledV1LegacyRoute; +export const PUT = disabledV1LegacyRoute; +export const PATCH = disabledV1LegacyRoute; +export const DELETE = disabledV1LegacyRoute; +export const OPTIONS = disabledV1LegacyRoute; +export const HEAD = disabledV1LegacyRoute; diff --git a/app/api/artifacts/route.ts b/app/api/artifacts/route.ts index 4707e795f0a..a5cb78861b9 100644 --- a/app/api/artifacts/route.ts +++ b/app/api/artifacts/route.ts @@ -1,73 +1,21 @@ -import md5 from "spark-md5"; -import { NextRequest, NextResponse } from "next/server"; -import { getServerSideConfig } from "@/app/config/server"; +import { NextResponse } from "next/server"; -async function handle(req: NextRequest, res: NextResponse) { - const serverConfig = getServerSideConfig(); - const storeUrl = () => - `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`; - const storeHeaders = () => ({ - Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`, - }); - if (req.method === "POST") { - const clonedBody = await req.text(); - const hashedCode = md5.hash(clonedBody).trim(); - const body: { - key: string; - value: string; - expiration_ttl?: number; - } = { - key: hashedCode, - value: clonedBody, - }; - try { - const ttl = parseInt(serverConfig.cloudflareKVTTL as string); - if (ttl > 60) { - body["expiration_ttl"] = ttl; - } - } catch (e) { - console.error(e); - } - const res = await fetch(`${storeUrl()}/bulk`, { - headers: { - ...storeHeaders(), - "Content-Type": "application/json", - }, - method: "PUT", - body: JSON.stringify([body]), - }); - const result = await res.json(); - console.log("save data", result); - if (result?.success) { - return NextResponse.json( - { code: 0, id: hashedCode, result }, - { status: res.status }, - ); - } - return NextResponse.json( - { error: true, msg: "Save data error" }, - { status: 400 }, - ); - } - if (req.method === "GET") { - const id = req?.nextUrl?.searchParams?.get("id"); - const res = await fetch(`${storeUrl()}/values/${id}`, { - headers: storeHeaders(), - method: "GET", - }); - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); - } +/** + * personChat V1:唯一聊天通道为 /backend-api/* → Backend /api/* → Gemini Web。 + * 旧 Cloudflare KV 分享代理已下线。 + * 所有 HTTP 方法统一返回 404,绝不进入原 Handler(ISSUE-01)。 + */ +function disabledV1LegacyRoute() { return NextResponse.json( - { error: true, msg: "Invalid request" }, - { status: 400 }, + { error: true, message: "This legacy route is disabled in personChat V1" }, + { status: 404 }, ); } -export const POST = handle; -export const GET = handle; - -export const runtime = "edge"; +export const GET = disabledV1LegacyRoute; +export const POST = disabledV1LegacyRoute; +export const PUT = disabledV1LegacyRoute; +export const PATCH = disabledV1LegacyRoute; +export const DELETE = disabledV1LegacyRoute; +export const OPTIONS = disabledV1LegacyRoute; +export const HEAD = disabledV1LegacyRoute; diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 855a5db017e..dee486fa4b4 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -6,15 +6,15 @@ const serverConfig = getServerSideConfig(); // Danger! Do not hard code any secret value here! // 警告!不要在这里写入任何敏感信息! +// +// personChat V1(ISSUE-01 / 验收计划 §5):/api/config 仅保留「访问控制 / 通用 UI 标志」, +// 供前端启动时准入判断使用(needCode)。已移除全部 Provider / 模型开关 +// (hideUserApiKey / disableGPT4 / customModels / defaultModel / visionModels), +// 不含任何 API Key 或 Provider 凭据。V1 唯一聊天通道为 /backend-api/*。 const DANGER_CONFIG = { needCode: serverConfig.needCode, - hideUserApiKey: serverConfig.hideUserApiKey, - disableGPT4: serverConfig.disableGPT4, hideBalanceQuery: serverConfig.hideBalanceQuery, disableFastLink: serverConfig.disableFastLink, - customModels: serverConfig.customModels, - defaultModel: serverConfig.defaultModel, - visionModels: serverConfig.visionModels, }; declare global { diff --git a/app/api/tencent/route.ts b/app/api/tencent/route.ts index fc4f8c79edf..2d0dbc1561f 100644 --- a/app/api/tencent/route.ts +++ b/app/api/tencent/route.ts @@ -1,117 +1,21 @@ -import { getServerSideConfig } from "@/app/config/server"; -import { TENCENT_BASE_URL, ModelProvider } from "@/app/constant"; -import { prettyObject } from "@/app/utils/format"; -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/app/api/auth"; -import { getHeader } from "@/app/utils/tencent"; - -const serverConfig = getServerSideConfig(); - -async function handle( - req: NextRequest, - { params }: { params: { path: string[] } }, -) { - console.log("[Tencent Route] params ", params); - - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - - const authResult = auth(req, ModelProvider.Hunyuan); - if (authResult.error) { - return NextResponse.json(authResult, { - status: 401, - }); - } - - try { - const response = await request(req); - return response; - } catch (e) { - console.error("[Tencent] ", e); - return NextResponse.json(prettyObject(e)); - } -} - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; - -async function request(req: NextRequest) { - const controller = new AbortController(); - - let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; - - if (!baseUrl.startsWith("http")) { - baseUrl = `https://${baseUrl}`; - } - - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.slice(0, -1); - } - - console.log("[Base Url]", baseUrl); - - const timeoutId = setTimeout( - () => { - controller.abort(); - }, - 10 * 60 * 1000, - ); - - const fetchUrl = baseUrl; - - const body = await req.text(); - const headers = await getHeader( - body, - serverConfig.tencentSecretId as string, - serverConfig.tencentSecretKey as string, +import { NextResponse } from "next/server"; + +/** + * personChat V1:唯一聊天通道为 /backend-api/* → Backend /api/* → Gemini Web。 + * 旧 Tencent Hunyuan Provider 代理已下线。 + * 所有 HTTP 方法统一返回 404,绝不进入原 Handler(ISSUE-01)。 + */ +function disabledV1LegacyRoute() { + return NextResponse.json( + { error: true, message: "This legacy route is disabled in personChat V1" }, + { status: 404 }, ); - const fetchOptions: RequestInit = { - headers, - method: req.method, - body, - redirect: "manual", - // @ts-ignore - duplex: "half", - signal: controller.signal, - }; - - try { - const res = await fetch(fetchUrl, fetchOptions); - - // to prevent browser prompt for credentials - const newHeaders = new Headers(res.headers); - newHeaders.delete("www-authenticate"); - // to disable nginx buffering - newHeaders.set("X-Accel-Buffering", "no"); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: newHeaders, - }); - } finally { - clearTimeout(timeoutId); - } } + +export const GET = disabledV1LegacyRoute; +export const POST = disabledV1LegacyRoute; +export const PUT = disabledV1LegacyRoute; +export const PATCH = disabledV1LegacyRoute; +export const DELETE = disabledV1LegacyRoute; +export const OPTIONS = disabledV1LegacyRoute; +export const HEAD = disabledV1LegacyRoute; diff --git a/app/api/upstash/[action]/[...key]/route.ts b/app/api/upstash/[action]/[...key]/route.ts index fcfef471862..518b166220a 100644 --- a/app/api/upstash/[action]/[...key]/route.ts +++ b/app/api/upstash/[action]/[...key]/route.ts @@ -1,73 +1,21 @@ -import { NextRequest, NextResponse } from "next/server"; - -async function handle( - req: NextRequest, - { params }: { params: { action: string; key: string[] } }, -) { - const requestUrl = new URL(req.url); - const endpoint = requestUrl.searchParams.get("endpoint"); - - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - const [...key] = params.key; - // only allow to request to *.upstash.io - if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) { - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + params.key.join("/"), - }, - { - status: 403, - }, - ); - } - - // only allow upstash get and set method - if (params.action !== "get" && params.action !== "set") { - console.log("[Upstash Route] forbidden action ", params.action); - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + params.action, - }, - { - status: 403, - }, - ); - } - - const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`; - - const method = req.method; - const shouldNotHaveBody = ["get", "head"].includes( - method?.toLowerCase() ?? "", +import { NextResponse } from "next/server"; + +/** + * personChat V1:唯一聊天通道为 /backend-api/* → Backend /api/* → Gemini Web。 + * 旧 Upstash 同步代理已下线。 + * 所有 HTTP 方法统一返回 404,绝不进入原 Handler(ISSUE-01)。 + */ +function disabledV1LegacyRoute() { + return NextResponse.json( + { error: true, message: "This legacy route is disabled in personChat V1" }, + { status: 404 }, ); - - const fetchOptions: RequestInit = { - headers: { - authorization: req.headers.get("authorization") ?? "", - }, - body: shouldNotHaveBody ? null : req.body, - method, - // @ts-ignore - duplex: "half", - }; - - console.log("[Upstash Proxy]", targetUrl, fetchOptions); - const fetchResult = await fetch(targetUrl, fetchOptions); - - console.log("[Any Proxy]", targetUrl, { - status: fetchResult.status, - statusText: fetchResult.statusText, - }); - - return fetchResult; } -export const POST = handle; -export const GET = handle; -export const OPTIONS = handle; - -export const runtime = "edge"; +export const GET = disabledV1LegacyRoute; +export const POST = disabledV1LegacyRoute; +export const PUT = disabledV1LegacyRoute; +export const PATCH = disabledV1LegacyRoute; +export const DELETE = disabledV1LegacyRoute; +export const OPTIONS = disabledV1LegacyRoute; +export const HEAD = disabledV1LegacyRoute; diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts index bb7743bda40..9448301ceee 100644 --- a/app/api/webdav/[...path]/route.ts +++ b/app/api/webdav/[...path]/route.ts @@ -1,167 +1,21 @@ -import { NextRequest, NextResponse } from "next/server"; -import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant"; -import { getServerSideConfig } from "@/app/config/server"; - -const config = getServerSideConfig(); - -const mergedAllowedWebDavEndpoints = [ - ...internalAllowedWebDavEndpoints, - ...config.allowedWebDavEndpoints, -].filter((domain) => Boolean(domain.trim())); - -const normalizeUrl = (url: string) => { - try { - return new URL(url); - } catch (err) { - return null; - } -}; - -async function handle( - req: NextRequest, - { params }: { params: { path: string[] } }, -) { - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - const folder = STORAGE_KEY; - const fileName = `${folder}/backup.json`; - - const requestUrl = new URL(req.url); - let endpoint = requestUrl.searchParams.get("endpoint"); - let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method; - - // Validate the endpoint to prevent potential SSRF attacks - if ( - !endpoint || - !mergedAllowedWebDavEndpoints.some((allowedEndpoint) => { - const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); - const normalizedEndpoint = normalizeUrl(endpoint as string); - - return ( - normalizedEndpoint && - normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && - normalizedEndpoint.pathname.startsWith( - normalizedAllowedEndpoint.pathname, - ) - ); - }) - ) { - return NextResponse.json( - { - error: true, - msg: "Invalid endpoint", - }, - { - status: 400, - }, - ); - } - - if (!endpoint?.endsWith("/")) { - endpoint += "/"; - } - - const endpointPath = params.path.join("/"); - const targetPath = `${endpoint}${endpointPath}`; - - // only allow MKCOL, GET, PUT - if ( - proxy_method !== "MKCOL" && - proxy_method !== "GET" && - proxy_method !== "PUT" - ) { - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + targetPath, - }, - { - status: 403, - }, - ); - } - - // for MKCOL request, only allow request ${folder} - if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) { - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + targetPath, - }, - { - status: 403, - }, - ); - } - - // for GET request, only allow request ending with fileName - if (proxy_method === "GET" && !targetPath.endsWith(fileName)) { - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + targetPath, - }, - { - status: 403, - }, - ); - } - - // for PUT request, only allow request ending with fileName - if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) { - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + targetPath, - }, - { - status: 403, - }, - ); - } - - const targetUrl = targetPath; - - const method = proxy_method || req.method; - const shouldNotHaveBody = ["get", "head"].includes( - method?.toLowerCase() ?? "", +import { NextResponse } from "next/server"; + +/** + * personChat V1:唯一聊天通道为 /backend-api/* → Backend /api/* → Gemini Web。 + * 旧 WebDAV 同步代理已下线(V1 聊天数据仅存于 Backend SQLite)。 + * 所有 HTTP 方法统一返回 404,绝不进入原 Handler(ISSUE-01)。 + */ +function disabledV1LegacyRoute() { + return NextResponse.json( + { error: true, message: "This legacy route is disabled in personChat V1" }, + { status: 404 }, ); - - const fetchOptions: RequestInit = { - headers: { - authorization: req.headers.get("authorization") ?? "", - }, - body: shouldNotHaveBody ? null : req.body, - redirect: "manual", - method, - // @ts-ignore - duplex: "half", - }; - - let fetchResult; - - try { - fetchResult = await fetch(targetUrl, fetchOptions); - } finally { - console.log( - "[Any Proxy]", - targetUrl, - { - method: method, - }, - { - status: fetchResult?.status, - statusText: fetchResult?.statusText, - }, - ); - } - - return fetchResult; } -export const PUT = handle; -export const GET = handle; -export const OPTIONS = handle; - -export const runtime = "edge"; +export const GET = disabledV1LegacyRoute; +export const POST = disabledV1LegacyRoute; +export const PUT = disabledV1LegacyRoute; +export const PATCH = disabledV1LegacyRoute; +export const DELETE = disabledV1LegacyRoute; +export const OPTIONS = disabledV1LegacyRoute; +export const HEAD = disabledV1LegacyRoute; diff --git a/app/client/backend-api.ts b/app/client/backend-api.ts new file mode 100644 index 00000000000..85a980fb051 --- /dev/null +++ b/app/client/backend-api.ts @@ -0,0 +1,390 @@ +/** + * personChat 后端 API 客户端(第 7 阶段)。 + * + * 浏览器只和同源 `/backend-api/*` 说话,由 next.config.mjs 代理到后端 `/api/*`, + * 所以这里不需要 CORS、也不需要上游 Provider 的任何鉴权头。 + */ + +const PREFIX = "/backend-api"; + +export type ConversationStatus = "ACTIVE" | "ARCHIVED"; +export type BackendMessageRole = "USER" | "ASSISTANT"; +/** Assistant Message 的状态机(与后端 Message.status 一一对应) */ +export type BackendMessageStatus = + | "PENDING" + | "STREAMING" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; +export type BackendRequestStatus = + | "PENDING" + | "PROCESSING" + | "CANCELLING" + | "SUCCESS" + | "FAILED" + | "TIMEOUT" + | "CANCELLED"; + +export interface BackendConversation { + id: string; + title: string; + status: ConversationStatus | "DELETED"; + provider: string; + providerConversationUrl: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface BackendRequestBrief { + id: string; + status: BackendRequestStatus; + errorCode: string | null; + errorMessage: string | null; +} + +export interface BackendMessage { + id: string; + conversationId: string; + role: BackendMessageRole; + content: string; + status: BackendMessageStatus; + position: number; + createdAt: string; + updatedAt: string; + /** 只有 ASSISTANT 消息带;USER 消息固定 null */ + request?: BackendRequestBrief | null; +} + +export interface BackendRequest { + id: string; + conversationId: string; + userMessageId: string; + assistantMessageId: string; + status: BackendRequestStatus; + errorCode: string | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SendMessageResult { + request: BackendRequest; + userMessage: BackendMessage; + assistantMessage: BackendMessage; + deduplicated: boolean; +} + +/** 后端统一错误信封 `{ error: { code, message, requestId } }` */ +export class BackendApiError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: number, + ) { + super(message); + this.name = "BackendApiError"; + } +} + +export const REQUEST_ACTIVE_STATUSES: BackendRequestStatus[] = [ + "PENDING", + "PROCESSING", + "CANCELLING", +]; + +export function isRequestFinished(status: BackendRequestStatus): boolean { + return !REQUEST_ACTIVE_STATUSES.includes(status); +} + +export function newIdempotencyKey(): string { + const cryptoRef = typeof crypto !== "undefined" ? crypto : undefined; + if (typeof cryptoRef?.randomUUID === "function") { + return `web-${cryptoRef.randomUUID()}`; + } + return `web-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}`; +} + +async function call( + path: string, + init: { + method?: string; + body?: unknown; + headers?: Record; + } = {}, +): Promise { + const response = await fetch(`${PREFIX}${path}`, { + method: init.method ?? "GET", + headers: { + ...(init.body === undefined + ? {} + : { "Content-Type": "application/json" }), + ...init.headers, + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + + if (response.status === 204) { + return undefined as T; + } + + const payload = await response.json().catch(() => null); + + if (!response.ok) { + const error = ( + payload as { error?: { code?: string; message?: string } } | null + )?.error; + throw new BackendApiError( + error?.code ?? "NETWORK_ERROR", + error?.message ?? `Request failed with status ${response.status}`, + response.status, + ); + } + + return (payload as { data: T }).data; +} + +export async function listConversations( + status: ConversationStatus = "ACTIVE", + cursor?: string | null, +): Promise<{ items: BackendConversation[]; nextCursor: string | null }> { + const query = new URLSearchParams({ status, limit: "50" }); + if (cursor) query.set("cursor", cursor); + const response = await fetch(`${PREFIX}/conversations?${query.toString()}`); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const error = ( + payload as { error?: { code?: string; message?: string } } | null + )?.error; + throw new BackendApiError( + error?.code ?? "NETWORK_ERROR", + error?.message ?? `Request failed with status ${response.status}`, + response.status, + ); + } + const body = payload as { + data: BackendConversation[]; + meta?: { nextCursor?: string | null }; + }; + return { items: body.data ?? [], nextCursor: body.meta?.nextCursor ?? null }; +} + +export function createConversation( + title?: string, +): Promise { + return call("/conversations", { + method: "POST", + body: title ? { title } : {}, + }); +} + +export function getConversation(id: string): Promise { + return call(`/conversations/${encodeURIComponent(id)}`); +} + +export function patchConversation( + id: string, + patch: { title?: string; status?: ConversationStatus }, +): Promise { + return call(`/conversations/${encodeURIComponent(id)}`, { + method: "PATCH", + body: patch, + }); +} + +export function deleteConversation(id: string): Promise { + return call(`/conversations/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function listMessages( + conversationId: string, +): Promise { + return call( + `/conversations/${encodeURIComponent(conversationId)}/messages`, + ); +} + +export function sendMessage( + conversationId: string, + content: string, + idempotencyKey: string, +): Promise { + return call( + `/conversations/${encodeURIComponent(conversationId)}/messages`, + { + method: "POST", + body: { content }, + headers: { "Idempotency-Key": idempotencyKey }, + }, + ); +} + +/** POST /api/requests/:id/cancel(prd §8.9) */ +export function cancelRequest(requestId: string): Promise { + return call( + `/requests/${encodeURIComponent(requestId)}/cancel`, + { method: "POST" }, + ); +} + +/** 后端 SSE 帧的 data 负载 */ +export interface RequestStatusFrame { + requestId: string; + /** Assistant Message 状态;会话还没写入时为 null */ + status: BackendMessageStatus | null; + requestStatus: BackendRequestStatus; + errorCode: string | null; + errorMessage: string | null; +} + +export interface RequestEventHandlers { + /** 当前完整文本(内部已把 delta 累加好,调用方只管覆盖渲染) */ + onContent(text: string): void; + onStatus?(frame: RequestStatusFrame): void; + /** 执行失败:带后端 errorCode */ + onError?(error: { code: string; message: string }): void; + /** 终态(COMPLETED / FAILED / TIMEOUT)后连接已关闭 */ + onFinish?(final: { + status: BackendMessageStatus | null; + requestStatus: BackendRequestStatus; + }): void; +} + +export interface RequestEventSubscription { + close(): void; +} + +const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000]; + +/** + * 订阅一条 Request 的回答流。 + * + * 用 EventSource 而不是手写 fetch:浏览器原生带断线重连。但重连语义要自己收口 —— + * 后端一进入终态就会主动结束响应,若不显式 close,EventSource 会不停重连一个已完成的 + * Request。内容累加以「本连接已发前缀」为基准:delta 是该前缀的后缀增量,重连首帧则是 + * 整段 snapshot(后端不重放历史 delta),它可能滞后于已渲染文本,只当作新前缀采用。 + */ +export function subscribeRequestEvents( + requestId: string, + handlers: RequestEventHandlers, +): RequestEventSubscription { + let closedByClient = false; + let finished = false; + let source: EventSource | null = null; + let retryTimer: ReturnType | null = null; + let attempt = 0; + /** 本连接上服务端已给出的完整文本:delta 是它的后缀增量,必须按它拼接 */ + let sent = ""; + /** 已经渲染给 UI 的文本:重连首帧快照可能滞后,不拿它回退已显示的内容 */ + let shown = ""; + + const readData = (event: Event): T | null => { + const raw = (event as MessageEvent).data; + if (typeof raw !== "string") return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }; + + const accept = (full: string) => { + sent = full; + if (full === shown) return; + if (shown.length > full.length && shown.startsWith(full)) return; + shown = full; + handlers.onContent(full); + }; + + const finish = (final: { + status: BackendMessageStatus | null; + requestStatus: BackendRequestStatus; + }) => { + if (finished) return; + finished = true; + closeSource(); + handlers.onFinish?.(final); + }; + + const closeSource = () => { + source?.close(); + source = null; + }; + + const scheduleReconnect = () => { + if (closedByClient || finished) return; + closeSource(); + const delay = + RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]; + attempt += 1; + retryTimer = setTimeout(open, delay); + }; + + function open() { + if (closedByClient || finished) return; + // 新连接上服务端按「本连接已发文本」重新推导前缀,首帧必是整段快照 + sent = ""; + source = new EventSource( + `${PREFIX}/requests/${encodeURIComponent(requestId)}/events`, + ); + + source.addEventListener("delta", (event) => { + const frame = readData<{ content: string }>(event); + if (!frame) return; + attempt = 0; + accept(sent + frame.content); + }); + + source.addEventListener("snapshot", (event) => { + const frame = readData<{ content: string }>(event); + if (!frame) return; + attempt = 0; + accept(frame.content); + }); + + source.addEventListener("status", (event) => { + const frame = readData(event); + if (!frame) return; + attempt = 0; + handlers.onStatus?.(frame); + if (isRequestFinished(frame.requestStatus)) { + finish({ + status: frame.status, + requestStatus: frame.requestStatus, + }); + } + }); + + // 后端的 `error` 事件名会被 EventSource 派发成同名 MessageEvent; + // 传输层错误则是普通 Event(没有 data),两者要分开处理。 + source.addEventListener("error", (event) => { + const frame = readData<{ code?: string; message?: string }>(event); + if (frame) { + handlers.onError?.({ + code: frame.code ?? "INTERNAL_ERROR", + message: frame.message ?? "Request failed", + }); + finish({ status: "FAILED", requestStatus: "FAILED" }); + return; + } + if (source?.readyState === EventSource.CLOSED) { + // 服务端已结束响应(多为终态后主动 close):重连一次读最终状态即可自愈 + scheduleReconnect(); + } + }); + } + + open(); + + return { + close() { + closedByClient = true; + if (retryTimer) clearTimeout(retryTimer); + closeSource(); + }, + }; +} diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx index 63dc4d5ff30..c4d2c92a33a 100644 --- a/app/components/chat-list.tsx +++ b/app/components/chat-list.tsx @@ -24,7 +24,8 @@ export function ChatItem(props: { onClick?: () => void; onDelete?: () => void; title: string; - count: number; + /** 未加载过消息的会话不展示条数 */ + count?: number; time: string; selected: boolean; id: string; @@ -42,6 +43,8 @@ export function ChatItem(props: { }, [props.selected]); const { pathname: currentPath } = useLocation(); + const countLabel = + props.count === undefined ? "" : Locale.ChatItem.ChatItemCount(props.count); return ( {(provided) => ( @@ -58,9 +61,7 @@ export function ChatItem(props: { }} {...provided.draggableProps} {...provided.dragHandleProps} - title={`${props.title}\n${Locale.ChatItem.ChatItemCount( - props.count, - )}`} + title={`${props.title}\n${countLabel}`} > {props.narrow ? (
@@ -70,17 +71,17 @@ export function ChatItem(props: { model={props.mask.modelConfig.model} />
-
- {props.count} -
+ {props.count !== undefined && ( +
+ {props.count} +
+ )} ) : ( <>
{props.title}
-
- {Locale.ChatItem.ChatItemCount(props.count)} -
+
{countLabel}
{props.time}
@@ -144,7 +145,7 @@ export function ChatList(props: { narrow?: boolean }) { .chat-message-container > .chat-message-item { background-color: var(--second); diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 6691403e65b..9c9bb6ceff9 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -10,256 +10,68 @@ import React, { } from "react"; import SendWhiteIcon from "../icons/send-white.svg"; -import BrainIcon from "../icons/brain.svg"; +import PauseIcon from "../icons/pause.svg"; import RenameIcon from "../icons/rename.svg"; -import EditIcon from "../icons/rename.svg"; import ExportIcon from "../icons/share.svg"; import ReturnIcon from "../icons/return.svg"; import CopyIcon from "../icons/copy.svg"; -import SpeakIcon from "../icons/speak.svg"; -import SpeakStopIcon from "../icons/speak-stop.svg"; import LoadingIcon from "../icons/three-dots.svg"; -import LoadingButtonIcon from "../icons/loading.svg"; import PromptIcon from "../icons/prompt.svg"; -import MaskIcon from "../icons/mask.svg"; import MaxIcon from "../icons/max.svg"; import MinIcon from "../icons/min.svg"; -import ResetIcon from "../icons/reload.svg"; import ReloadIcon from "../icons/reload.svg"; -import BreakIcon from "../icons/break.svg"; -import SettingsIcon from "../icons/chat-settings.svg"; -import DeleteIcon from "../icons/clear.svg"; -import PinIcon from "../icons/pin.svg"; import ConfirmIcon from "../icons/confirm.svg"; -import CloseIcon from "../icons/close.svg"; -import CancelIcon from "../icons/cancel.svg"; -import ImageIcon from "../icons/image.svg"; import LightIcon from "../icons/light.svg"; import DarkIcon from "../icons/dark.svg"; import AutoIcon from "../icons/auto.svg"; import BottomIcon from "../icons/bottom.svg"; -import StopIcon from "../icons/pause.svg"; -import RobotIcon from "../icons/robot.svg"; -import SizeIcon from "../icons/size.svg"; -import QualityIcon from "../icons/hd.svg"; -import StyleIcon from "../icons/palette.svg"; -import PluginIcon from "../icons/plugin.svg"; import ShortcutkeyIcon from "../icons/shortcutkey.svg"; -import McpToolIcon from "../icons/tool.svg"; -import HeadphoneIcon from "../icons/headphone.svg"; +import ArchiveIcon from "../icons/archive.svg"; import { - BOT_HELLO, ChatMessage, createMessage, DEFAULT_TOPIC, - ModelType, + errorTextForCode, SubmitKey, Theme, - useAccessStore, useAppConfig, useChatStore, - usePluginStore, } from "../store"; import { autoGrowTextArea, copyToClipboard, - getMessageImages, getMessageTextContent, - isDalle3, - isVisionModel, safeLocalStorage, - getModelSizes, - supportsCustomSize, useMobileScreen, - selectOrCopy, - showPlugins, } from "../utils"; -import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; - import dynamic from "next/dynamic"; -import { ChatControllerPool } from "../client/controller"; -import { DalleQuality, DalleStyle, ModelSize } from "../typing"; import { Prompt, usePromptStore } from "../store/prompt"; import Locale from "../locales"; import { IconButton } from "./button"; import styles from "./chat.module.scss"; -import { - List, - ListItem, - Modal, - Selector, - showConfirm, - showPrompt, - showToast, -} from "./ui-lib"; +import { Modal, showPrompt } from "./ui-lib"; import { useNavigate } from "react-router-dom"; -import { - CHAT_PAGE_SIZE, - DEFAULT_TTS_ENGINE, - ModelProvider, - Path, - REQUEST_TIMEOUT_MS, - ServiceProvider, - UNFINISHED_INPUT, -} from "../constant"; +import { CHAT_PAGE_SIZE, Path, UNFINISHED_INPUT } from "../constant"; import { Avatar } from "./emoji"; -import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; -import { useMaskStore } from "../store/mask"; +import { MaskAvatar } from "./mask"; import { ChatCommandPrefix, useChatCommand, useCommand } from "../command"; -import { prettyObject } from "../utils/format"; import { ExportMessageModal } from "./exporter"; import { getClientConfig } from "../config/client"; -import { useAllModels } from "../utils/hooks"; -import { ClientApi, MultimodalContent } from "../client/api"; -import { createTTSPlayer } from "../utils/audio"; -import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts"; - -import { isEmpty } from "lodash-es"; -import { getModelProvider } from "../utils/model"; -import { RealtimeChat } from "@/app/components/realtime-chat"; + import clsx from "clsx"; -import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions"; const localStorage = safeLocalStorage(); -const ttsPlayer = createTTSPlayer(); - const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , }); -const MCPAction = () => { - const navigate = useNavigate(); - const [count, setCount] = useState(0); - const [mcpEnabled, setMcpEnabled] = useState(false); - - useEffect(() => { - const checkMcpStatus = async () => { - const enabled = await isMcpEnabled(); - setMcpEnabled(enabled); - if (enabled) { - const count = await getAvailableClientsCount(); - setCount(count); - } - }; - checkMcpStatus(); - }, []); - - if (!mcpEnabled) return null; - - return ( - navigate(Path.McpMarket)} - text={`MCP${count ? ` (${count})` : ""}`} - icon={} - /> - ); -}; - -export function SessionConfigModel(props: { onClose: () => void }) { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const maskStore = useMaskStore(); - const navigate = useNavigate(); - - return ( -
- props.onClose()} - actions={[ - } - bordered - text={Locale.Chat.Config.Reset} - onClick={async () => { - if (await showConfirm(Locale.Memory.ResetConfirm)) { - chatStore.updateTargetSession( - session, - (session) => (session.memoryPrompt = ""), - ); - } - }} - />, - } - bordered - text={Locale.Chat.Config.SaveAs} - onClick={() => { - navigate(Path.Masks); - setTimeout(() => { - maskStore.create(session.mask); - }, 500); - }} - />, - ]} - > - { - const mask = { ...session.mask }; - updater(mask); - chatStore.updateTargetSession( - session, - (session) => (session.mask = mask), - ); - }} - shouldSyncFromGlobal - extraListItems={ - session.mask.modelConfig.sendMemory ? ( - - ) : ( - <> - ) - } - > - -
- ); -} - -function PromptToast(props: { - showToast?: boolean; - showModal?: boolean; - setShowModal: (_: boolean) => void; -}) { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const context = session.mask.context; - - return ( -
- {props.showToast && context.length > 0 && ( -
props.setShowModal(true)} - > - - - {Locale.Context.Toast(context.length)} - -
- )} - {props.showModal && ( - props.setShowModal(false)} /> - )} -
- ); -} - function useSubmitHandler() { const config = useAppConfig(); const submitKey = config.submitKey; @@ -379,28 +191,6 @@ export function PromptHints(props: { ); } -function ClearContextDivider() { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - - return ( -
- chatStore.updateTargetSession( - session, - (session) => (session.clearContextIndex = undefined), - ) - } - > -
{Locale.Context.Clear}
-
- {Locale.Context.Revert} -
-
- ); -} - export function ChatAction(props: { text: string; icon: JSX.Element; @@ -492,23 +282,12 @@ function useScrollToBottom( } export function ChatActions(props: { - uploadImage: () => void; - setAttachImages: (images: string[]) => void; - setUploading: (uploading: boolean) => void; - showPromptModal: () => void; scrollToBottom: () => void; showPromptHints: () => void; hitBottom: boolean; - uploading: boolean; setShowShortcutKeyModal: React.Dispatch>; - setUserInput: (input: string) => void; - setShowChatSidePanel: React.Dispatch>; }) { const config = useAppConfig(); - const navigate = useNavigate(); - const chatStore = useChatStore(); - const pluginStore = usePluginStore(); - const session = chatStore.currentSession(); // switch themes const theme = config.theme; @@ -521,91 +300,11 @@ export function ChatActions(props: { config.update((config) => (config.theme = nextTheme)); } - // stop all responses - const couldStop = ChatControllerPool.hasPending(); - const stopAll = () => ChatControllerPool.stopAll(); - - // switch model - const currentModel = session.mask.modelConfig.model; - const currentProviderName = - session.mask.modelConfig?.providerName || ServiceProvider.OpenAI; - const allModels = useAllModels(); - const models = useMemo(() => { - const filteredModels = allModels.filter((m) => m.available); - const defaultModel = filteredModels.find((m) => m.isDefault); - - if (defaultModel) { - const arr = [ - defaultModel, - ...filteredModels.filter((m) => m !== defaultModel), - ]; - return arr; - } else { - return filteredModels; - } - }, [allModels]); - const currentModelName = useMemo(() => { - const model = models.find( - (m) => - m.name == currentModel && - m?.provider?.providerName == currentProviderName, - ); - return model?.displayName ?? ""; - }, [models, currentModel, currentProviderName]); - const [showModelSelector, setShowModelSelector] = useState(false); - const [showPluginSelector, setShowPluginSelector] = useState(false); - const [showUploadImage, setShowUploadImage] = useState(false); - - const [showSizeSelector, setShowSizeSelector] = useState(false); - const [showQualitySelector, setShowQualitySelector] = useState(false); - const [showStyleSelector, setShowStyleSelector] = useState(false); - const modelSizes = getModelSizes(currentModel); - const dalle3Qualitys: DalleQuality[] = ["standard", "hd"]; - const dalle3Styles: DalleStyle[] = ["vivid", "natural"]; - const currentSize = - session.mask.modelConfig?.size ?? ("1024x1024" as ModelSize); - const currentQuality = session.mask.modelConfig?.quality ?? "standard"; - const currentStyle = session.mask.modelConfig?.style ?? "vivid"; - const isMobileScreen = useMobileScreen(); - useEffect(() => { - const show = isVisionModel(currentModel); - setShowUploadImage(show); - if (!show) { - props.setAttachImages([]); - props.setUploading(false); - } - - // if current model is not available - // switch to first available model - const isUnavailableModel = !models.some((m) => m.name === currentModel); - if (isUnavailableModel && models.length > 0) { - // show next model to default model if exist - let nextModel = models.find((model) => model.isDefault) || models[0]; - chatStore.updateTargetSession(session, (session) => { - session.mask.modelConfig.model = nextModel.name; - session.mask.modelConfig.providerName = nextModel?.provider - ?.providerName as ServiceProvider; - }); - showToast( - nextModel?.provider?.providerName == "ByteDance" - ? nextModel.displayName - : nextModel.name, - ); - } - }, [chatStore, currentModel, models, session]); - return (
<> - {couldStop && ( - } - /> - )} {!props.hitBottom && ( } /> )} - {props.hitBottom && ( - } - /> - )} - - {showUploadImage && ( - : } - /> - )} } /> - { - navigate(Path.Masks); - }} - text={Locale.Chat.InputActions.Masks} - icon={} - /> - - } - onClick={() => { - chatStore.updateTargetSession(session, (session) => { - if (session.clearContextIndex === session.messages.length) { - session.clearContextIndex = undefined; - } else { - session.clearContextIndex = session.messages.length; - session.memoryPrompt = ""; // will clear memory - } - }); - }} - /> - - setShowModelSelector(true)} - text={currentModelName} - icon={} - /> - - {showModelSelector && ( - ({ - title: `${m.displayName}${ - m?.provider?.providerName - ? " (" + m?.provider?.providerName + ")" - : "" - }`, - value: `${m.name}@${m?.provider?.providerName}`, - }))} - onClose={() => setShowModelSelector(false)} - onSelection={(s) => { - if (s.length === 0) return; - const [model, providerName] = getModelProvider(s[0]); - chatStore.updateTargetSession(session, (session) => { - session.mask.modelConfig.model = model as ModelType; - session.mask.modelConfig.providerName = - providerName as ServiceProvider; - session.mask.syncGlobalConfig = false; - }); - if (providerName == "ByteDance") { - const selectedModel = models.find( - (m) => - m.name == model && - m?.provider?.providerName == providerName, - ); - showToast(selectedModel?.displayName ?? ""); - } else { - showToast(model); - } - }} - /> - )} - - {supportsCustomSize(currentModel) && ( - setShowSizeSelector(true)} - text={currentSize} - icon={} - /> - )} - - {showSizeSelector && ( - ({ - title: m, - value: m, - }))} - onClose={() => setShowSizeSelector(false)} - onSelection={(s) => { - if (s.length === 0) return; - const size = s[0]; - chatStore.updateTargetSession(session, (session) => { - session.mask.modelConfig.size = size; - }); - showToast(size); - }} - /> - )} - - {isDalle3(currentModel) && ( - setShowQualitySelector(true)} - text={currentQuality} - icon={} - /> - )} - - {showQualitySelector && ( - ({ - title: m, - value: m, - }))} - onClose={() => setShowQualitySelector(false)} - onSelection={(q) => { - if (q.length === 0) return; - const quality = q[0]; - chatStore.updateTargetSession(session, (session) => { - session.mask.modelConfig.quality = quality; - }); - showToast(quality); - }} - /> - )} - - {isDalle3(currentModel) && ( - setShowStyleSelector(true)} - text={currentStyle} - icon={} - /> - )} - - {showStyleSelector && ( - ({ - title: m, - value: m, - }))} - onClose={() => setShowStyleSelector(false)} - onSelection={(s) => { - if (s.length === 0) return; - const style = s[0]; - chatStore.updateTargetSession(session, (session) => { - session.mask.modelConfig.style = style; - }); - showToast(style); - }} - /> - )} - - {showPlugins(currentProviderName, currentModel) && ( - { - if (pluginStore.getAll().length == 0) { - navigate(Path.Plugins); - } else { - setShowPluginSelector(true); - } - }} - text={Locale.Plugin.Name} - icon={} - /> - )} - {showPluginSelector && ( - ({ - title: `${item?.title}@${item?.version}`, - value: item?.id, - }))} - onClose={() => setShowPluginSelector(false)} - onSelection={(s) => { - chatStore.updateTargetSession(session, (session) => { - session.mask.plugin = s as string[]; - }); - }} - /> - )} - {!isMobileScreen && ( props.setShowShortcutKeyModal(true)} @@ -832,89 +341,7 @@ export function ChatActions(props: { icon={} /> )} - {!isMobileScreen && } -
- {config.realtimeConfig.enable && ( - props.setShowChatSidePanel(true)} - text={"Realtime Chat"} - icon={} - /> - )} -
-
- ); -} - -export function EditMessageModal(props: { onClose: () => void }) { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const [messages, setMessages] = useState(session.messages.slice()); - - return ( -
- } - key="cancel" - onClick={() => { - props.onClose(); - }} - />, - } - key="ok" - onClick={() => { - chatStore.updateTargetSession( - session, - (session) => (session.messages = messages), - ); - props.onClose(); - }} - />, - ]} - > - - - - chatStore.updateTargetSession( - session, - (session) => (session.topic = e.currentTarget.value), - ) - } - > - - - { - const newMessages = messages.slice(); - updater(newMessages); - setMessages(newMessages); - }} - /> - -
- ); -} - -export function DeleteImageButton(props: { deleteImage: () => void }) { - return ( -
-
); } @@ -939,12 +366,6 @@ export function ShortcutKeyModal(props: { onClose: () => void }) { title: Locale.Chat.ShortcutKey.showShortcutKey, keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"], }, - { - title: Locale.Chat.ShortcutKey.clearContext, - keys: isMac - ? ["⌘", "Shift", "backspace"] - : ["Ctrl", "Shift", "backspace"], - }, ]; return (
@@ -999,7 +420,6 @@ function _Chat() { const inputRef = useRef(null); const [userInput, setUserInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); const { submitKey, shouldSubmit } = useSubmitHandler(); const scrollRef = useRef(null); const isScrolledToBottom = scrollRef?.current @@ -1031,8 +451,6 @@ function _Chat() { const [hitBottom, setHitBottom] = useState(true); const isMobileScreen = useMobileScreen(); const navigate = useNavigate(); - const [attachImages, setAttachImages] = useState([]); - const [uploading, setUploading] = useState(false); // prompt hints const promptStore = usePromptStore(); @@ -1073,12 +491,6 @@ function _Chat() { newm: () => navigate(Path.NewChat), prev: () => chatStore.nextSession(-1), next: () => chatStore.nextSession(1), - clear: () => - chatStore.updateTargetSession( - session, - (session) => (session.clearContextIndex = session.messages.length), - ), - fork: () => chatStore.forkSession(), del: () => chatStore.deleteSession(chatStore.currentSessionIndex), }); @@ -1103,7 +515,7 @@ function _Chat() { }; const doSubmit = (userInput: string) => { - if (userInput.trim() === "" && isEmpty(attachImages)) return; + if (userInput.trim() === "") return; const matchCommand = chatCommands.match(userInput); if (matchCommand.matched) { setUserInput(""); @@ -1111,11 +523,7 @@ function _Chat() { matchCommand.invoke(); return; } - setIsLoading(true); - chatStore - .onUserInput(userInput, attachImages) - .then(() => setIsLoading(false)); - setAttachImages([]); + chatStore.onUserInput(userInput); chatStore.setLastInput(userInput); setUserInput(""); setPromptHints([]); @@ -1140,40 +548,6 @@ function _Chat() { }, 30); }; - // stop response - const onUserStop = (messageId: string) => { - ChatControllerPool.stop(session.id, messageId); - }; - - useEffect(() => { - chatStore.updateTargetSession(session, (session) => { - const stopTiming = Date.now() - REQUEST_TIMEOUT_MS; - session.messages.forEach((m) => { - // check if should stop all stale messages - if (m.isError || new Date(m.date).getTime() < stopTiming) { - if (m.streaming) { - m.streaming = false; - } - - if (m.content.length === 0) { - m.isError = true; - m.content = prettyObject({ - error: true, - message: "empty response", - }); - } - } - }); - - // auto sync mask config from global config - if (session.mask.syncGlobalConfig) { - console.log("[Mask] syncing from global, name = ", session.mask.name); - session.mask.modelConfig = { ...config.modelConfig }; - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session]); - // check if should send message const onInputKeyDown = (e: React.KeyboardEvent) => { // if ArrowUp and no userInput, fill with last input @@ -1191,197 +565,23 @@ function _Chat() { e.preventDefault(); } }; - const onRightClick = (e: any, message: ChatMessage) => { - // copy to clipboard - if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) { - if (userInput.length === 0) { - setUserInput(getMessageTextContent(message)); - } - - e.preventDefault(); - } - }; - - const deleteMessage = (msgId?: string) => { - chatStore.updateTargetSession( - session, - (session) => - (session.messages = session.messages.filter((m) => m.id !== msgId)), - ); - }; - - const onDelete = (msgId: string) => { - deleteMessage(msgId); - }; - - const onResend = (message: ChatMessage) => { - // when it is resending a message - // 1. for a user's message, find the next bot response - // 2. for a bot's message, find the last user's input - // 3. delete original user input and bot's message - // 4. resend the user's input - - const resendingIndex = session.messages.findIndex( - (m) => m.id === message.id, - ); - - if (resendingIndex < 0 || resendingIndex >= session.messages.length) { - console.error("[Chat] failed to find resending message", message); - return; - } - - let userMessage: ChatMessage | undefined; - let botMessage: ChatMessage | undefined; - - if (message.role === "assistant") { - // if it is resending a bot's message, find the user input for it - botMessage = message; - for (let i = resendingIndex; i >= 0; i -= 1) { - if (session.messages[i].role === "user") { - userMessage = session.messages[i]; - break; - } - } - } else if (message.role === "user") { - // if it is resending a user's input, find the bot's response - userMessage = message; - for (let i = resendingIndex; i < session.messages.length; i += 1) { - if (session.messages[i].role === "assistant") { - botMessage = session.messages[i]; - break; - } - } - } - - if (userMessage === undefined) { - console.error("[Chat] failed to resend", message); - return; - } - - // delete the original messages - deleteMessage(userMessage.id); - deleteMessage(botMessage?.id); - - // resend the message - setIsLoading(true); - const textContent = getMessageTextContent(userMessage); - const images = getMessageImages(userMessage); - chatStore.onUserInput(textContent, images).then(() => setIsLoading(false)); - inputRef.current?.focus(); - }; - - const onPinMessage = (message: ChatMessage) => { - chatStore.updateTargetSession(session, (session) => - session.mask.context.push(message), - ); - - showToast(Locale.Chat.Actions.PinToastContent, { - text: Locale.Chat.Actions.PinToastAction, - onClick: () => { - setShowPromptModal(true); - }, - }); - }; - - const accessStore = useAccessStore(); - const [speechStatus, setSpeechStatus] = useState(false); - const [speechLoading, setSpeechLoading] = useState(false); - - async function openaiSpeech(text: string) { - if (speechStatus) { - ttsPlayer.stop(); - setSpeechStatus(false); - } else { - var api: ClientApi; - api = new ClientApi(ModelProvider.GPT); - const config = useAppConfig.getState(); - setSpeechLoading(true); - ttsPlayer.init(); - let audioBuffer: ArrayBuffer; - const { markdownToTxt } = require("markdown-to-txt"); - const textContent = markdownToTxt(text); - if (config.ttsConfig.engine !== DEFAULT_TTS_ENGINE) { - const edgeVoiceName = accessStore.edgeVoiceName(); - const tts = new MsEdgeTTS(); - await tts.setMetadata( - edgeVoiceName, - OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, - ); - audioBuffer = await tts.toArrayBuffer(textContent); - } else { - audioBuffer = await api.llm.speech({ - model: config.ttsConfig.model, - input: textContent, - voice: config.ttsConfig.voice, - speed: config.ttsConfig.speed, - }); - } - setSpeechStatus(true); - ttsPlayer - .play(audioBuffer, () => { - setSpeechStatus(false); - }) - .catch((e) => { - console.error("[OpenAI Speech]", e); - showToast(prettyObject(e)); - setSpeechStatus(false); - }) - .finally(() => setSpeechLoading(false)); - } - } - - const context: RenderMessage[] = useMemo(() => { - return session.mask.hideContext ? [] : session.mask.context.slice(); - }, [session.mask.context, session.mask.hideContext]); - - if ( - context.length === 0 && - session.messages.at(0)?.content !== BOT_HELLO.content - ) { - const copiedHello = Object.assign({}, BOT_HELLO); - if (!accessStore.isAuthorized()) { - copiedHello.content = Locale.Error.Unauthorized; - } - context.push(copiedHello); - } - - // preview messages + // 历史消息完全来自后端,不再注入本地 context 与 BOT_HELLO 占位: + // 空会话就是空的,首条回答由后端建 Conversation 后才出现。 const renderMessages = useMemo(() => { - return context - .concat(session.messages as RenderMessage[]) - .concat( - isLoading - ? [ - { - ...createMessage({ - role: "assistant", - content: "……", - }), - preview: true, - }, - ] - : [], - ) - .concat( - userInput.length > 0 && config.sendPreviewBubble - ? [ - { - ...createMessage({ - role: "user", - content: userInput, - }), - preview: true, - }, - ] - : [], - ); - }, [ - config.sendPreviewBubble, - context, - isLoading, - session.messages, - userInput, - ]); + return (session.messages as RenderMessage[]).concat( + userInput.length > 0 && config.sendPreviewBubble + ? [ + { + ...createMessage({ + role: "user", + content: userInput, + }), + preview: true, + }, + ] + : [], + ); + }, [config.sendPreviewBubble, session.messages, userInput]); const [msgRenderIndex, _setMsgRenderIndex] = useState( Math.max(0, renderMessages.length - CHAT_PAGE_SIZE), @@ -1428,13 +628,22 @@ function _Chat() { scrollDomToBottom(); } - // clear context index = context length + index in messages - const clearContextIndex = - (session.clearContextIndex ?? -1) >= 0 - ? session.clearContextIndex! + context.length - msgRenderIndex - : -1; + async function renameCurrentSession() { + const title = await showPrompt(Locale.Chat.Rename, session.topic); + if (title) { + await chatStore.renameSession(session.id, title); + } + } - const [showPromptModal, setShowPromptModal] = useState(false); + function toggleArchiveSession() { + const index = chatStore.sessions.findIndex((s) => s.id === session.id); + if (index < 0) return; + if (session.conversationStatus === "ARCHIVED") { + chatStore.restoreSession(index); + } else { + chatStore.archiveSession(index); + } + } const clientConfig = useMemo(() => getClientConfig(), []); @@ -1446,52 +655,8 @@ function _Chat() { submit: (text) => { doSubmit(text); }, - code: (text) => { - if (accessStore.disableFastLink) return; - console.log("[Command] got code from url: ", text); - showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => { - if (res) { - accessStore.update((access) => (access.accessCode = text)); - } - }); - }, - settings: (text) => { - if (accessStore.disableFastLink) return; - - try { - const payload = JSON.parse(text) as { - key?: string; - url?: string; - }; - - console.log("[Command] got settings from url: ", payload); - - if (payload.key || payload.url) { - showConfirm( - Locale.URLCommand.Settings + - `\n${JSON.stringify(payload, null, 4)}`, - ).then((res) => { - if (!res) return; - if (payload.key) { - accessStore.update( - (access) => (access.openaiApiKey = payload.key!), - ); - } - if (payload.url) { - accessStore.update((access) => (access.openaiUrl = payload.url!)); - } - accessStore.update((access) => (access.useCustomConfig = true)); - }); - } - } catch { - console.error("[Command] failed to get settings from url: ", text); - } - }, }); - // edit / insert message modal - const [isEditingMessage, setIsEditingMessage] = useState(false); - // remember unfinished input useEffect(() => { // try to load from local storage @@ -1509,94 +674,6 @@ function _Chat() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handlePaste = useCallback( - async (event: React.ClipboardEvent) => { - const currentModel = chatStore.currentSession().mask.modelConfig.model; - if (!isVisionModel(currentModel)) { - return; - } - const items = (event.clipboardData || window.clipboardData).items; - for (const item of items) { - if (item.kind === "file" && item.type.startsWith("image/")) { - event.preventDefault(); - const file = item.getAsFile(); - if (file) { - const images: string[] = []; - images.push(...attachImages); - images.push( - ...(await new Promise((res, rej) => { - setUploading(true); - const imagesData: string[] = []; - uploadImageRemote(file) - .then((dataUrl) => { - imagesData.push(dataUrl); - setUploading(false); - res(imagesData); - }) - .catch((e) => { - setUploading(false); - rej(e); - }); - })), - ); - const imagesLength = images.length; - - if (imagesLength > 3) { - images.splice(3, imagesLength - 3); - } - setAttachImages(images); - } - } - } - }, - [attachImages, chatStore], - ); - - async function uploadImage() { - const images: string[] = []; - images.push(...attachImages); - - images.push( - ...(await new Promise((res, rej) => { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = - "image/png, image/jpeg, image/webp, image/heic, image/heif"; - fileInput.multiple = true; - fileInput.onchange = (event: any) => { - setUploading(true); - const files = event.target.files; - const imagesData: string[] = []; - for (let i = 0; i < files.length; i++) { - const file = event.target.files[i]; - uploadImageRemote(file) - .then((dataUrl) => { - imagesData.push(dataUrl); - if ( - imagesData.length === 3 || - imagesData.length === files.length - ) { - setUploading(false); - res(imagesData); - } - }) - .catch((e) => { - setUploading(false); - rej(e); - }); - } - }; - fileInput.click(); - })), - ); - - const imagesLength = images.length; - if (imagesLength > 3) { - images.splice(3, imagesLength - 3); - } - setAttachImages(images); - } - // 快捷键 shortcut keys const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false); @@ -1652,22 +729,6 @@ function _Chat() { event.preventDefault(); setShowShortcutKeyModal(true); } - // 清除上下文 command + shift + backspace - else if ( - (event.metaKey || event.ctrlKey) && - event.shiftKey && - event.key.toLowerCase() === "backspace" - ) { - event.preventDefault(); - chatStore.updateTargetSession(session, (session) => { - if (session.clearContextIndex === session.messages.length) { - session.clearContextIndex = undefined; - } else { - session.clearContextIndex = session.messages.length; - session.memoryPrompt = ""; // will clear memory - } - }); - } }; document.addEventListener("keydown", handleKeyDown); @@ -1675,9 +736,7 @@ function _Chat() { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [messages, chatStore, navigate, session]); - - const [showChatSidePanel, setShowChatSidePanel] = useState(false); + }, [messages, chatStore, navigate]); return ( <> @@ -1704,7 +763,7 @@ function _Chat() { "window-header-main-title", styles["chat-body-main-title"], )} - onClickCapture={() => setIsEditingMessage(true)} + onClickCapture={renameCurrentSession} > {!session.topic ? DEFAULT_TOPIC : session.topic}
@@ -1717,11 +776,8 @@ function _Chat() { } bordered - title={Locale.Chat.Actions.RefreshTitle} - onClick={() => { - showToast(Locale.Chat.Actions.RefreshToast); - chatStore.summarizeSession(true, session); - }} + title={Locale.Chat.Actions.Refresh} + onClick={() => chatStore.refreshSessionMessages(session.id)} /> {!isMobileScreen && ( @@ -1729,9 +785,29 @@ function _Chat() { } bordered - title={Locale.Chat.EditMessage.Title} - aria={Locale.Chat.EditMessage.Title} - onClick={() => setIsEditingMessage(true)} + title={Locale.Chat.Rename} + aria={Locale.Chat.Rename} + onClick={renameCurrentSession} + /> + + )} + {!session.draft && ( +
+ + ) : ( + + ) + } + bordered + title={ + session.conversationStatus === "ARCHIVED" + ? Locale.Chat.Actions.Restore + : Locale.Chat.Actions.Archive + } + onClick={toggleArchiveSession} />
)} @@ -1761,12 +837,6 @@ function _Chat() { )} - -
@@ -1780,264 +850,99 @@ function _Chat() { setAutoScroll(false); }} > - {messages - // TODO - // .filter((m) => !m.isMcpResponse) - .map((message, i) => { - const isUser = message.role === "user"; - const isContext = i < context.length; - const showActions = - i > 0 && - !(message.preview || message.content.length === 0) && - !isContext; - const showTyping = message.preview || message.streaming; - - const shouldShowClearContextDivider = - i === clearContextIndex - 1; - - return ( - -
-
-
-
-
- } - aria={Locale.Chat.Actions.Edit} - onClick={async () => { - const newMessage = await showPrompt( - Locale.Chat.Actions.Edit, - getMessageTextContent(message), - 10, - ); - let newContent: - | string - | MultimodalContent[] = newMessage; - const images = getMessageImages(message); - if (images.length > 0) { - newContent = [ - { type: "text", text: newMessage }, - ]; - for (let i = 0; i < images.length; i++) { - newContent.push({ - type: "image_url", - image_url: { - url: images[i], - }, - }); - } + {messages.map((message, i) => { + const isUser = message.role === "user"; + const showActions = + i > 0 && + !(message.preview || message.content.length === 0) && + !message.streaming; + + return ( + +
+
+
+
+ {isUser ? ( + + ) : ( + <> + {["system"].includes(message.role) ? ( + + ) : ( + { - const m = session.mask.context - .concat(session.messages) - .find((m) => m.id === message.id); - if (m) { - m.content = newContent; - } - }, - ); - }} - > -
- {isUser ? ( - - ) : ( - <> - {["system"].includes(message.role) ? ( - - ) : ( - - )} - - )} -
- {!isUser && ( -
- {message.model} -
- )} - - {showActions && ( -
-
- {message.streaming ? ( - } - onClick={() => - onUserStop(message.id ?? i) - } - /> - ) : ( - <> - } - onClick={() => onResend(message)} - /> - - } - onClick={() => - onDelete(message.id ?? i) - } - /> - - } - onClick={() => onPinMessage(message)} - /> - } - onClick={() => - copyToClipboard( - getMessageTextContent(message), - ) - } - /> - {config.ttsConfig.enable && ( - - ) : ( - - ) - } - onClick={() => - openaiSpeech( - getMessageTextContent(message), - ) - } - /> - )} - - )} -
-
+ /> + )} + )}
- {message?.tools?.length == 0 && showTyping && ( -
- {Locale.Chat.Typing} + {!isUser && ( +
+ {message.model}
)} - {/*@ts-ignore*/} - {message?.tools?.length > 0 && ( -
- {message?.tools?.map((tool) => ( -
- {tool.isError === false ? ( - - ) : tool.isError === true ? ( - - ) : ( - - )} - {tool?.function?.name} -
- ))} + + {showActions && ( +
+
+ } + onClick={() => + copyToClipboard( + getMessageTextContent(message), + ) + } + /> +
)} -
- onRightClick(e, message)} // hard to use - onDoubleClickCapture={() => { - if (!isMobileScreen) return; - setUserInput(getMessageTextContent(message)); - }} - fontSize={fontSize} - fontFamily={fontFamily} - parentRef={scrollRef} - defaultShow={i >= messages.length - 6} - /> - {getMessageImages(message).length == 1 && ( - - )} - {getMessageImages(message).length > 1 && ( -
- {getMessageImages(message).map( - (image, index) => { - return ( - - ); - }, - )} -
- )} -
- {message?.audio_url && ( -
-
+
+ onRightClick(e, message)} // hard to use + onDoubleClickCapture={() => { + if (!isMobileScreen) return; + setUserInput(getMessageTextContent(message)); + }} + fontSize={fontSize} + fontFamily={fontFamily} + parentRef={scrollRef} + defaultShow={i >= messages.length - 6} + /> + {message.isError && ( +
+ {errorTextForCode(message.errorCode)}
)} +
-
- {isContext - ? Locale.Chat.IsContext - : message.date.toLocaleString()} -
+
+ {message.date.toLocaleString()}
- {shouldShowClearContextDivider && } - - ); - })} +
+ + ); + })}
setShowPromptModal(true)} scrollToBottom={scrollToBottom} hitBottom={hitBottom} - uploading={uploading} showPromptHints={() => { // Click again to close if (promptHints.length > 0) { @@ -2065,14 +965,9 @@ function _Chat() { onSearch(""); }} setShowShortcutKeyModal={setShowShortcutKeyModal} - setUserInput={setUserInput} - setShowChatSidePanel={setShowChatSidePanel} />