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/.github/workflows/app.yml b/.github/workflows/app.yml index 7e74cf04595..2c0cb3b4b79 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -18,7 +18,7 @@ jobs: - name: setup node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 - name: get version run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV - name: create release @@ -56,7 +56,7 @@ jobs: - name: setup node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 cache: 'yarn' - name: install Rust stable uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index faf7205d9cb..76e4c65f899 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 cache: "yarn" - name: Cache node_modules diff --git a/Dockerfile b/Dockerfile index d3e4193eef2..0e10e5e625f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine AS base +FROM node:24-alpine AS base FROM base AS deps diff --git a/README.md b/README.md index 8271d5d8c1d..5d77bec8b66 100644 --- a/README.md +++ b/README.md @@ -1,482 +1,467 @@ -
- - - icon - - -

NextChat

- -English / [简体中文](./README_CN.md) - -ChatGPTNextWeb%2FChatGPT-Next-Web | Trendshift - -✨ Light and Fast AI Assistant,with Claude, DeepSeek, GPT4 & Gemini Pro support. - -[![Saas][Saas-image]][saas-url] -[![Web][Web-image]][web-url] -[![Windows][Windows-image]][download-url] -[![MacOS][MacOS-image]][download-url] -[![Linux][Linux-image]][download-url] - -[NextChatAI](https://nextchat.club?utm_source=readme) / [iOS APP](https://apps.apple.com/us/app/nextchat-ai/id6743085599) / [Web App Demo](https://app.nextchat.club) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Enterprise Edition](#enterprise-edition) - -[saas-url]: https://nextchat.club?utm_source=readme -[saas-image]: https://img.shields.io/badge/NextChat-Saas-green?logo=microsoftedge -[web-url]: https://app.nextchat.club/ -[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases -[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge -[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows -[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple -[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu - -[Deploy on Zeabur](https://zeabur.com/templates/ZBUEFA) [Deploy on Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [Open in Gitpod](https://gitpod.io/#https://github.com/ChatGPTNextWeb/NextChat) [Deploy with your agent](https://opendeploy.dev/github/ChatGPTNextWeb/NextChat) - -[](https://monica.im/?utm=nxcrp) - -
- -## ❤️ Sponsor AI API - - - icon - - -[302.AI](https://302.ai/) is a pay-as-you-go AI application platform that offers the most comprehensive AI APIs and online applications available. - -## 🥳 Cheer for NextChat iOS Version Online! - -> [👉 Click Here to Install Now](https://apps.apple.com/us/app/nextchat-ai/id6743085599) - -> [❤️ Source Code Coming Soon](https://github.com/ChatGPTNextWeb/NextChat-iOS) - -![Github iOS Image](https://github.com/user-attachments/assets/e0aa334f-4c13-4dc9-8310-e3b09fa4b9f3) - -## 🫣 NextChat Support MCP ! - -> Before build, please set env ENABLE_MCP=true - - - -## Enterprise Edition - -Meeting Your Company's Privatization and Customization Deployment Requirements: - -- **Brand Customization**: Tailored VI/UI to seamlessly align with your corporate brand image. -- **Resource Integration**: Unified configuration and management of dozens of AI resources by company administrators, ready for use by team members. -- **Permission Control**: Clearly defined member permissions, resource permissions, and knowledge base permissions, all controlled via a corporate-grade Admin Panel. -- **Knowledge Integration**: Combining your internal knowledge base with AI capabilities, making it more relevant to your company's specific business needs compared to general AI. -- **Security Auditing**: Automatically intercept sensitive inquiries and trace all historical conversation records, ensuring AI adherence to corporate information security standards. -- **Private Deployment**: Enterprise-level private deployment supporting various mainstream private cloud solutions, ensuring data security and privacy protection. -- **Continuous Updates**: Ongoing updates and upgrades in cutting-edge capabilities like multimodal AI, ensuring consistent innovation and advancement. - -For enterprise inquiries, please contact: **business@nextchat.club** - -## Screenshots - -![Settings](./docs/images/settings.png) - -![More](./docs/images/more.png) - -## Features - -- **Deploy for free with one-click** on Vercel in under 1 minute -- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) -- Fully compatible with self-deployed LLMs, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI) -- Privacy first, all data is stored locally in the browser -- Markdown support: LaTex, mermaid, code highlight, etc. -- Responsive design, dark mode and PWA -- Fast first screen loading speed (~100kb), support streaming response -- New in v2: create, share and debug your chat tools with prompt templates (mask) -- Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) -- Automatically compresses chat history to support long conversations while also saving your tokens -- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia - -
- -![主界面](./docs/images/cover.png) - -
- -## Roadmap - -- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) -- [x] User Prompt: user can edit and save custom prompts to prompt list -- [x] Prompt Template: create a new chat with pre-defined in-context prompts [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993) -- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) -- [x] Desktop App with tauri -- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc. -- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) -- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - - [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) -- [x] Supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672) -- [ ] local knowledge base - -## What's New - -- 🚀 v2.15.8 Now supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672) -- 🚀 v2.15.4 The Application supports using Tauri fetch LLM API, MORE SECURITY! [#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379) -- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) -- 🚀 v2.14.0 Now supports Artifacts & SD -- 🚀 v2.10.1 support Google Gemini Pro model. -- 🚀 v2.9.11 you can use azure endpoint now. -- 🚀 v2.8 now we have a client that runs across all platforms! -- 🚀 v2.7 let's share conversations as image, or share to ShareGPT! -- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/). - -## Get Started - -1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys); -2. Click - [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password; -3. Enjoy :) - -## FAQ - -[English > FAQ](./docs/faq-en.md) - -## Keep Updated - -If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly. - -We recommend that you follow the steps below to re-deploy: - -- Delete the original repository; -- Use the fork button in the upper right corner of the page to fork this project; -- Choose and deploy in Vercel again, [please see the detailed tutorial](./docs/vercel-cn.md). - -### Enable Automatic Updates - -> If you encounter a failure of Upstream Sync execution, please [manually update code](./README.md#manually-updating-code). - -After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour: - -![Automatic Updates](./docs/images/enable-actions.jpg) - -![Enable Automatic Updates](./docs/images/enable-actions-sync.jpg) - -### Manually Updating Code - -If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. - -You can star or watch this project or follow author to get release notifications in time. - -## Access Password - -This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this: - -``` -code1,code2,code3 +# personChat Frontend + +基于 NextChat 改造的 personChat Web 前端。 + +保留 NextChat 的主要聊天体验、Markdown 渲染、会话列表与设置界面;Conversation / Message / Request 已不再由浏览器本地聊天存储作为权威数据源,而是统一由 personChat Backend 管理。Frontend 通过 `/backend-api/*` 调用后端,并通过 SSE 接收 Gemini Web 的实时回答。 + +Frontend 不直接调用 Gemini API,也不直接驱动 Gemini Web —— 它只负责 UI 和交互,所有聊天能力都由 personChat Backend 提供。 + +## 系统架构 + +```text +Browser + │ + ▼ +personChat Frontend + │ + │ REST / EventSource + ▼ +/backend-api/* + │ + │ Next.js Rewrite + ▼ +personChat Backend + │ + ▼ +Playwright / Gemini Web ``` -After adding or modifying this environment variable, please redeploy the project for the changes to take effect. - -## Environment Variables - -### `CODE` (optional) - -Access password, separated by comma. - -### `OPENAI_API_KEY` (required) - -Your openai api key, join multiple api keys with comma. - -### `BASE_URL` (optional) - -> Default: `https://api.openai.com` - -> Examples: `http://your-openai-proxy.com` - -Override openai api request base url. - -### `OPENAI_ORG_ID` (optional) - -Specify OpenAI organization ID. - -### `AZURE_URL` (optional) - -> Example: https://{azure-resource-url}/openai - -Azure deploy url. - -### `AZURE_API_KEY` (optional) - -Azure Api Key. - -### `AZURE_API_VERSION` (optional) - -Azure Api Version, find it at [Azure Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions). - -### `GOOGLE_API_KEY` (optional) - -Google Gemini Pro Api Key. - -### `GOOGLE_URL` (optional) - -Google Gemini Pro Api Url. - -### `ANTHROPIC_API_KEY` (optional) - -anthropic claude Api Key. - -### `ANTHROPIC_API_VERSION` (optional) - -anthropic claude Api version. - -### `ANTHROPIC_URL` (optional) - -anthropic claude Api Url. - -### `BAIDU_API_KEY` (optional) - -Baidu Api Key. - -### `BAIDU_SECRET_KEY` (optional) - -Baidu Secret Key. - -### `BAIDU_URL` (optional) - -Baidu Api Url. - -### `BYTEDANCE_API_KEY` (optional) - -ByteDance Api Key. +Frontend 只负责 UI 和交互;Backend 是 Conversation / Message / Request 的权威数据源。 + +## 与原 NextChat 的主要区别 + +### 已改造 + +- **Conversation / Message / Request 后端化**:全部由 Backend 管理,聊天主数据源是 Backend 数据库。 +- **SSE 流式**:通过 `GET /backend-api/requests/:id/events` 接收流式回答(见下文「SSE 流式」)。 +- **Cancel 后端化**:停止生成走后端 Cancel 接口(见下文「Cancel」)。 +- **Archive / Restore / Delete 后端化**:会话归档、恢复、删除都以后端为准。 +- **Draft 首次发送时创建 Backend Conversation**:新建草稿不落库,第一次发送才创建(见下文「首次发送行为」)。 +- **页面刷新从 Backend 恢复**:重新拉取 Conversation / Message;生成中刷新会重新跟随 Request 继续 SSE。 +- **发送幂等**:每次发送带 `Idempotency-Key`,防止重复提交。 + +### 已禁用 / 隐藏 + +以下能力在 V1 已从 UI 移除或返回 404(详见「旧接口与旧能力」): + +- 旧多 Provider 代理路由(OpenAI / Anthropic / Google / Azure / 百度 / 字节 / 阿里 / Moonshot / Stability / 讯飞 / DeepSeek / xAI / GLM / SiliconFlow / 302.AI 等)及默认 Proxy +- API Key 配置入口(设置页已无 Key 输入) +- 模型切换(固定使用 `Gemini Web` 通道;V1.1 新增的会话级 Gemini 模型选择见下文「模型选择(V1.1)」,不属于 NextChat 原生 Provider 体系) +- WebDAV / Upstash 同步 +- ShareGPT 分享 +- Artifacts(Cloudflare KV 分享代理已下线,前端接口返回 404) + +## 技术栈 + +| 依赖 | 版本 | +| --- | --- | +| Next.js | ^14.1.1 | +| React | ^18.2.0 | +| TypeScript | 5.2.2 | +| Zustand | ^4.3.8 | +| React Markdown | ^8.0.7 | +| Yarn | 1.22.19(`packageManager` 锁定) | + +已验证 Node 版本:**Node 24.14.0**。仓库未声明最低 Node 版本。 + +## 关键目录 + +```text +app/ +├─ client/ +│ ├─ backend-api.ts # REST / SSE 后端通信客户端 +│ └─ admin-api.ts # canonical /backend-api/admin/* 运维调用 +├─ store/ +│ ├─ chat.ts # 前端会话状态与 Backend 数据同步 +│ ├─ auth.ts # 身份 session 真相(服务端为准)+ 登录 / 退出 / 吊销 +│ └─ browser.ts # 浏览器状态快照 + 共享轮询定时器 +├─ components/ +│ ├─ chat.tsx # 聊天主界面 +│ ├─ sidebar.tsx # 会话列表 / 归档切换 +│ ├─ browser-status.tsx # /admin 用的服务端浏览器面板(展示层) +│ └─ settings.tsx # 设置页(无运维面板) +├─ admin/ +│ ├─ page.tsx # /admin 管理控制台(ADMIN only) +│ └─ login/page.tsx # /admin/login 管理员登录 +└─ api/ + ├─ config/route.ts # 保留:非敏感 UI 配置 + └─ (其余 Provider / WebDAV 路由已统一 404) + +next.config.mjs # /backend-api/* rewrite 规则 +``` -### `BYTEDANCE_URL` (optional) +- `backend-api.ts`:所有后端请求的唯一出口,只和同源 `/backend-api/*` 说话,无 CORS、无 Provider 鉴权头。 +- `admin-api.ts`:运维调用与 Public 聊天调用物理分开,类型独立,不共享 DTO。 +- `chat.ts`:会话状态机(草稿 / 已加载 / 在途 Request / 归档),与 Backend 数据同步。 +- `auth.ts`:身份只由 `GET /backend-api/auth/session` 决定,本地不落任何凭证。 +- `browser.ts`:浏览器状态快照、重启动作与引用计数的共享轮询(展示位只有一份 15s 轮询)。 +- `next.config.mjs`:`/backend-api/*` 同源代理。 +- `docs/browser-status-api.md`:浏览器状态接口的后端实现规格(交后端 Agent 的交付文档)。 -ByteDance Api Url. +> 原 NextChat 的多 Provider 客户端(`app/client/platforms/*`、`app/client/api.ts`)仍在仓库中,但已不参与聊天链路,仅为残留代码。 -### `ALIBABA_API_KEY` (optional) +## 环境要求 -Alibaba Cloud Api Key. +- Node.js +- Yarn 1.x +- personChat Backend(正在运行) +- 现代浏览器(Chrome / Edge / Firefox) -### `ALIBABA_URL` (optional) +Frontend 本身不需要 Prisma、SQLite 或 Playwright Chromium —— 这些属于 Backend。 -Alibaba Cloud Api Url. +## 快速开始 -### `IFLYTEK_URL` (Optional) +### 1. Clone -iflytek Api Url. +```bash +git clone https://github.com/hukaiedu/NextChatfront.git +cd NextChatfront +``` -### `IFLYTEK_API_KEY` (Optional) +### 2. 安装 -iflytek Api Key. +```bash +yarn install +``` -### `IFLYTEK_API_SECRET` (Optional) +### 3. Backend -iflytek Api Secret. +先确保 personChat Backend 已启动。 -### `CHATGLM_API_KEY` (optional) +相关仓库: -ChatGLM Api Key. +```text +https://github.com/hukaiedu/NextChatBack +``` -### `CHATGLM_URL` (optional) +默认 Backend 地址为 `http://127.0.0.1:3010`(如需修改见下节)。 -ChatGLM Api Url. +没有 Backend 时,前端页面可以打开,但无法登录会话列表、无法聊天。 -### `DEEPSEEK_API_KEY` (optional) +## BACKEND_ORIGIN -DeepSeek Api Key. +Frontend 通过 Next.js Rewrite 将 `/backend-api/*` 代理到后端,目标地址由环境变量 `BACKEND_ORIGIN` 指定(`next.config.mjs` 在 dev server / build 启动时读取): -### `DEEPSEEK_URL` (optional) +```bash +BACKEND_ORIGIN=http://127.0.0.1:3010 +``` -DeepSeek Api Url. +请求链路: -### `HIDE_USER_API_KEY` (optional) +```text +Browser 请求 /backend-api/* +↓ +Next.js Rewrite(beforeFiles) +↓ +BACKEND_ORIGIN/api/* +``` -> Default: Empty +未设置 `BACKEND_ORIGIN` 时默认为 `http://127.0.0.1:3010`。该变量只在启动时读取一次,修改后需重启 dev server / 重新构建。 -If you do not want users to input their own API key, set this value to 1. +## V1.6 身份与账号体验 -### `DISABLE_GPT4` (optional) +身份只有 `ANONYMOUS`、`REGISTERED`、`ADMIN`。普通用户使用 username/password: -> Default: Empty +- `/` 为 anonymous-first;普通用户入口是 `/login`、`/register`,管理员入口独立为 `/admin/login`,控制台为 `/admin`。 +- 注册调用 `POST /backend-api/auth/register`,保留当前 `User.id`、chat 与 messages,UI 不因身份变更而清空。 +- 已有账号登录调用 `POST /backend-api/auth/user/login`;会关闭已有 streams、清除旧 conversation UI 与未发送敏感 draft,再加载目标账号数据,不迁移当前匿名聊天。 +- 改密调用 `POST /backend-api/auth/password/change`,保持同一 subject;`POST /backend-api/auth/sessions/revoke-all` 撤销该用户全部 Session。 +- logout、revoke-all、identity lost 是真正的 subject transition;多 Tab 在 focus/visibility 时重新 probe,mutation 不自动 replay。 -If you do not want users to use GPT-4, set this value to 1. +前端不实现 email、OAuth、2FA、password recovery 或 account deletion。Session 由 Backend 以随机 opaque token 管理,数据库只保存 token 的 SHA-256 hash;前端不读取 Cookie。 -### `ENABLE_BALANCE_QUERY` (optional) +V1.6 发布范围:支持游客模式、游客注册升级、登录、登出和修改密码;暂不支持邮箱验证、找回密码、OAuth、2FA 或多设备账号管理。正式开放由 Backend 部署配置控制:生产环境必须启用 `AUTH_ENABLED=true`,并配置安全的账号密码与 HTTPS Origin 白名单。 -> Default: Empty +## 启动开发环境 -If you do want users to query balance, set this value to 1. +```bash +yarn dev +``` -### `DISABLE_FAST_LINK` (optional) +默认前端端口为 **3000**(Next.js 默认,项目未另行指定),打开 http://localhost:3000 即可使用。 -> Default: Empty +## 生产构建 -If you want to disable parse settings from url, set this to 1. +```bash +yarn build +yarn start +``` -### `CUSTOM_MODELS` (optional) +`yarn build` 以 `BUILD_MODE=standalone` 执行 Next.js standalone 构建;另有 `yarn export` 生成纯静态导出(该模式无 rewrite,聊天通道不可用,仅用于纯前端预览)。 + +## 聊天数据来源 + +Conversation、Message、Request 全部来自 personChat Backend: + +- 会话列表:`GET /backend-api/conversations?status=ACTIVE|ARCHIVED` +- 消息历史:`GET /backend-api/conversations/:id/messages` +- 发送消息:`POST /backend-api/conversations/:id/messages`(带 `Idempotency-Key`) +- 停止生成:`POST /backend-api/requests/:id/cancel` +- 事件流:`GET /backend-api/requests/:id/events`(SSE) +- 服务端浏览器状态:不属于聊天主数据,只在 `/admin` 管理控制台按 canonical 路径读取(见「服务端浏览器管理(V1.3-C)」一节) + +Frontend **不再将本地 IndexedDB / localStorage 中的聊天记录视为权威数据**。数据库恢复、刷新恢复都以后端为准。 + +## 本地存储策略 + +| 内容 | 存储位置 | 说明 | +| --- | --- | --- | +| Theme / UI preference(字号、字体、发送键等) | localStorage | `chat-next-web-config` | +| Mask(角色预设) | localStorage | `chat-next-web-mask` | +| Prompt 资源 | localStorage | `chat-next-web-prompt` | +| 未发送输入 | localStorage | `unfinished-input-<会话 id>`(仅草稿文本,切换会话时恢复) | +| 服务端浏览器状态快照 | 内存 | 每次进入页面重新拉取,不落盘 | +| 登录身份 / Session | HttpOnly Cookie + 内存 store | 前端代码不读 Cookie、不落盘,身份真相只来自 `GET /backend-api/auth/session` | +| 聊天数据(旧 `chat-next-web-store`) | 不持久化 | 启动时主动清除 | + +- Conversation / Message / Request **不作为聊天主数据持久化到本地**。 +- 旧版本曾把聊天数据写入本地 `chat-next-web-store`(IndexedDB / localStorage),现在 `bootstrap()` 首屏会主动清除,本地残留不会覆盖 Backend 数据。 +- 会话拖动排序仅为本次浏览的临时顺序,Backend 没有排序字段。 + +## 首次发送行为 + +用户新建会话时只是一个本地草稿(`draft-*`),**不创建 Backend Conversation**: + +```text +用户新建草稿 +↓ +此时不创建 Backend Conversation +↓ +用户第一次发送 +↓ +POST /backend-api/conversations(创建 Backend Conversation,标题取首条消息) +↓ +POST /backend-api/conversations/:id/messages(得到 Request) +↓ +订阅 SSE,开始跟随流式回答 +``` -> Default: Empty -> Example: `+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` means add `llama, claude-2` to model list, and remove `gpt-3.5-turbo` from list, and display `gpt-4-1106-preview` as `gpt-4-turbo`. +## 模型选择(V1.1) -To control custom models, use `+` to add a custom model, use `-` to hide a model, use `name=displayName` to customize model name, separated by comma. +聊天头部的模型选择器由 personChat Backend 驱动,**不属于 NextChat 原生 Provider/模型体系**(后者保持禁用): -User `-all` to disable all default models, `+all` to enable all default models. +- **目录来源**:`GET /backend-api/provider/models`(后端从 Gemini Web 页面实时读取),前端零硬编码模型;目录加载失败时弹层提供重试项,不阻塞聊天主流程。 +- **会话偏好**:已落库会话经 `PATCH /backend-api/conversations/:id`(body `preferredModelKey`)保存,乐观更新、失败回滚并提示;「默认模型」= `preferredModelKey=null`,**不是一个伪造的模型 id**。 +- **Draft 语义**:草稿(`draft-*`)里选模型只改内存,**0 后端请求**;首次发送创建 Backend Conversation 时,若草稿带偏好,POST body 显式携带 `modelKey`,后端同事务写入会话偏好与 Request 快照;草稿无偏好则只发 `{content}`。 +- **已落库会话**:普通发送 body 只含 `{content}`,**永不携带 `modelKey`**;后端按会话偏好冻结该次 Request 的 `requestedModelKey`。生成中(PENDING / PROCESSING / CANCELLING)选择器禁用。 +- **stale key 安全**:历史偏好键不在当前目录时,按钮显示「当前模型不可用」,**不自动清除偏好**;此时发送会被后端判 `PROVIDER_MODEL_UNAVAILABLE`(内部码,Request 终态 FAILED),Public 信封只回 `CHAT_FAILED`,气泡显示通用错误文案,偏好保持不变。 +- **偏好持久化唯一来源是后端**:localStorage 不存任何模型偏好;页面刷新后偏好与会话历史均从 Backend 恢复。 -For Azure: use `modelName@Azure=deploymentName` to customize model name and deployment name. +## 管理控制台与服务端浏览器(V1.3-C) -> Example: `+gpt-3.5-turbo@Azure=gpt35` will show option `gpt35(Azure)` in model list. -> If you only can use Azure model, `-all,+gpt-3.5-turbo@Azure=gpt35` will `gpt35(Azure)` the only option in model list. +`/admin`(App Router 真实路由,**不是** HashRouter 里的 `#/…`)是唯一的管理界面;未登录 ADMIN 时跳 `/admin/login` 只输密码。聊天侧栏与设置页不再有运维面板。 -For ByteDance: use `modelName@bytedance=deploymentName` to customize model name and deployment name. +- **身份真相**:只来自 `GET /backend-api/auth/session`(HttpOnly Cookie 由浏览器自动携带,前端代码从不读 Cookie);管理员密码不写 localStorage / sessionStorage,不进 URL,不打日志。 +- **canonical 路径**:浏览器状态走 `GET /backend-api/admin/browser/status`,重启走 `POST /backend-api/admin/browser/restart`;Provider 状态 / 打开 / 重启走 `GET /backend-api/admin/provider/status`、`POST /backend-api/admin/provider/open`、`POST /backend-api/admin/provider/restart`(旧 `/backend-api/browser/*`、`/backend-api/provider/status` 等已随后端 alias 退役)。 +- **面板内容**:当前身份与登录状态有效期、浏览器 `state` / 启动时间 / 已运行时长 / 类型 / profileDir / Gemini 登录态 / 进行中的回答 / 最近错误 / 状态更新时间。 +- **Provider 面板**:`state` 取 `STOPPED` / `STARTING` / `LOGIN_REQUIRED` / `READY` / `BUSY` / `ERROR`;只在进入页面时拉一次,靠「刷新」手动更新(不参与上面的 15s 轮询);「打开 Provider」只把后端浏览器页面切到前台,不确认、不写数据;「重启 Provider」先确认再执行,后端以原始错误码拒绝时(如 `503 PROVIDER_NOT_READY`)原样展示,不跳转、不降级成通用文案。 +- **轮询**:挂载时立即拉一次,之后每 15s 一次;页面隐藏时跳过本轮但不停表,切回前台即恢复新鲜度。 +- **逐字段降级**:除 `state` 外全部可选,后端缺哪个字段该行就显示 `—`;首帧之前显示「未知」。 +- **错误区分**:网络错误按 HTTP 状态分流,`404` 视为「后端尚未提供 canonical Admin API,请升级 personChat Backend」,与「连不上后端服务」分开;拉取失败时保留上一次快照不清空。 +- **危险操作一律先确认**:重启(提示会中断正在进行的回答)、吊销全部登录状态(`POST /backend-api/admin/sessions/revoke-all`,提示不可撤销,成功后当前 Cookie 失效并回登录页)、退出管理员登录(只注销当前设备)。 +- **不落本地**:浏览器状态与登录态只存在于内存 store,localStorage 不写任何浏览器状态或会话数据。 +- **后端规格**:`docs/browser-status-api.md`(字段语义、状态机、`BROWSER_*` 内部错误码、并发约束与验收清单)。 -> Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list. +## 图片附件(V1.2) -### `DEFAULT_MODEL` (optional) +- 支持从输入区选择 PNG / JPEG / WebP / GIF。 +- 最多 4 张;最终单图 ≤ 5MiB、总计 ≤ 10MiB。 +- 支持纯图片消息。 +- 支持通过输入框粘贴图片(Chromium 已验证;Safari 尚未正式验收)。 +- hard reload 后历史图片显示占位信息;原图字节不持久化,不会恢复历史原图。 -Change default model +## SSE 流式 -### `VISION_MODELS` (optional) +前端通过 `GET /backend-api/requests/:id/events`(经 rewrite 到后端 `/api/requests/:id/events`)用 `EventSource` 订阅一条 Request 的回答流。 -> Default: Empty -> Example: `gpt-4-vision,claude-3-opus,my-custom-model` means add vision capabilities to these models in addition to the default pattern matches (which detect models containing keywords like "vision", "claude-3", "gemini-1.5", etc). +前端处理的事件: -Add additional models to have vision capabilities, beyond the default pattern matching. Multiple models should be separated by commas. +| 事件 | 作用 | +| --- | --- | +| `snapshot` | 用当前完整内容重新同步(重连首帧必是整段快照,后端不重放历史 delta) | +| `delta` | 追加内容增量 | +| `status` | 更新 Request / Message 状态 | +| `error` | 显示错误信息并进入终态 | -### `WHITE_WEBDAV_ENDPOINTS` (optional) +- 连接断开时按 1s / 2s / 4s / 8s / 15s 退避自动重连;Request 到达终态后连接会正常关闭。 +- 内容拼接以「本连接已发送前缀」为基准:delta 是该前缀的增量,snapshot 用完整内容覆盖(且不会回退已渲染的更长文本)。 +- 到达终态时若本地气泡仍为空,会回读一次消息历史,避免把空气泡留给用户。 -You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format: +Request 状态机:`PENDING → PROCESSING →(CANCELLING)→ SUCCESS / FAILED / TIMEOUT / CANCELLED`。 -- Each address must be a complete endpoint - > `https://xxxx/yyy` -- Multiple addresses are connected by ', ' +## 刷新恢复 -### `DEFAULT_INPUT_TEMPLATE` (optional) +```text +浏览器刷新 +↓ +从 Backend 重新获取会话列表与消息历史 +``` -Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. +如果刷新时有正在生成的回答: -### `STABILITY_API_KEY` (optional) +```text +打开该会话,读取消息中的 active Request +↓ +重新建立 SSE 连接 +↓ +首帧 snapshot 对齐已生成内容 +↓ +继续 streaming +``` -Stability API key. +刷新不会重发 Prompt。 -### `STABILITY_URL` (optional) +## Cancel -Customize Stability API url. +```text +生成中,发送按钮变为停止按钮 +↓ +点击停止 → POST /backend-api/requests/:id/cancel +↓ +Request 进入 CANCELLING +↓ +等待终态 +``` -### `ENABLE_MCP` (optional) +- **CANCELLING 期间停止按钮不可重复触发**(disabled)。 +- 最终状态为 `CANCELLED`,已生成的部分内容保留显示,不算错误。 +- 若后端返回 `REQUEST_NOT_CANCELLABLE`,前端会刷新会话消息以对齐后端状态。 -Enable MCP(Model Context Protocol)Feature +## Conversation 操作 -### `SILICONFLOW_API_KEY` (optional) +会话列表支持: -SiliconFlow API Key. +- **创建**:新建草稿,首次发送时落库 +- **重命名**:已落库会话 `PATCH title` +- **Archive**:归档会话(`PATCH status=ARCHIVED`),从「进行中」列表移除 +- **Restore**:在归档列表恢复(`PATCH status=ACTIVE`) +- **Delete**:后端软删除,**删除后不提供旧 NextChat 的 5 秒本地撤销**(后端删除不可恢复) -### `SILICONFLOW_URL` (optional) +列表可在「进行中(ACTIVE)」和「归档(ARCHIVED)」之间切换。 -SiliconFlow API URL. +## 错误显示 -### `AI302_API_KEY` (optional) +前端只消费后端 **Public 信封**的错误码(V1.3-C 契约):内部实现码(`PROVIDER_*`、`BROWSER_*`、`DATABASE_ERROR`、`INTERNAL_ERROR` …)在 HTTP 响应体、SSE 帧与 DTO 里都已折成通用码,前端码表因此**不含任何内部码**——出现 `PROVIDER_*` 就说明 Public 契约漏了细节。 -302.AI API Key. +映射唯一来源:`app/store/chat.ts` 的 `ERROR_TEXT` + `errorTextForCode()` / `backendErrorMessage()`。**未列出的码一律回落到 `请求失败,请重试。`,绝不把后端原始 code 或 message 渲染给用户。** -### `AI302_URL` (optional) +| 错误码 | 用户文案 | +| --- | --- | +| `CHAT_FAILED` | 请求失败,请重试。(也是未知码的兜底) | +| `SERVICE_BUSY` | 服务暂时繁忙,请稍后重试。 | +| `REQUEST_TIMEOUT` | 请求超时,请重试。 | +| `CONVERSATION_NOT_FOUND` | 会话不存在 | +| `CONVERSATION_DELETED` | 会话已删除 | +| `CONVERSATION_ARCHIVED` | 会话已归档,请先恢复后再发送 | +| `CONVERSATION_REQUEST_IN_PROGRESS` | 这个会话还有回答在进行中,请先等它完成 | +| `REQUEST_NOT_FOUND` | 这个回答已不存在 | +| `REQUEST_NOT_CANCELLABLE` | 这个回答已经结束,无需停止 | +| `IDEMPOTENCY_KEY_REUSED` | 这条消息已提交过,请重新发送 | +| `VALIDATION_ERROR` | 请求内容不符合要求,请检查后重试 | +| `PAYLOAD_TOO_LARGE` | 图片请求数据过大,请减少图片或缩小后重试 | +| `ATTACHMENT_TOO_LARGE` | 图片过大、数量过多或总大小超限 | +| `UNSUPPORTED_ATTACHMENT_TYPE` | 仅支持 PNG/JPEG/WebP/GIF | +| `AUTH_REQUIRED` | 登录状态已更新,请重试 | +| `AUTH_FORBIDDEN` | 没有权限执行该操作 | +| `AUTH_CSRF_REJECTED` | 请求已失效,请刷新页面后重试 | +| `NETWORK_ERROR` | 网络异常,请稍后重试(前端本地生成,表示连不上后端) | -302.AI API URL. +- `CANCELLED` 不是错误码:用户主动停止时正常展示已生成内容。 +- 「模型不可用」「服务端浏览器未运行」等运维细节改由 `/admin` 管理控制台呈现,聊天界面只给上面的通用文案。 -## Requirements +## 旧接口与旧能力 -NodeJS >= 18, Docker >= 20 +V1 不再使用 NextChat 原 Provider API。以下路由已统一返回 **404**: -## Development +- `/api/openai/*`、`/api/anthropic/*`、`/api/google/*` 等全部旧 Provider 代理(经 `app/api/[provider]/[...path]` 统一关闭) +- `/api/webdav/*`(WebDAV 同步代理) +- `/api/upstash/*`(Upstash 同步代理) +- `/api/tencent`(腾讯混元代理) +- `/api/artifacts`(Cloudflare KV 分享代理) -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) +保留的 `/api/config` **只返回非敏感 UI 配置**(`needCode` / `hideBalanceQuery` / `disableFastLink`,用于访问控制准入判断),不含任何 API Key 或 Provider 凭据,不是 Provider 配置接口。 -Before starting development, you must create a new `.env.local` file at project root, and place your api key into it: +## 开发与测试 -``` -OPENAI_API_KEY= +```bash +# 单元测试(CI 模式,V1.2 冻结验收:43 个测试套件 / 440 个测试 PASS) +yarn test:ci -# if you are not able to access openai service, use this BASE_URL -BASE_URL=https://chatgpt1.nextweb.fun/api/proxy -``` +# TypeScript 类型检查 +npx tsc --noEmit -### Local Development +# 生产构建验证 +yarn build -```shell -# 1. install nodejs and yarn first -# 2. config local env vars in `.env.local` -# 3. run -yarn install -yarn dev +# ESLint +yarn lint ``` -## Deployment - -### Docker (Recommended) +**Windows 注意**:`yarn test:ci` / `yarn test` 脚本内的 `$(yarn bin jest)` 是 bash 语法,在 Windows 的 yarn(cmd 执行)下会失败。Windows 下请直接运行: -```shell -docker pull yidadaa/chatgpt-next-web - -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=your-password \ - yidadaa/chatgpt-next-web +```bash +node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js --ci ``` -You can start service behind a proxy: - -```shell -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=your-password \ - -e PROXY_URL=http://localhost:7890 \ - yidadaa/chatgpt-next-web -``` +Lint 说明:`.eslintrc.json` 中为兼容当前工具链关闭了 `unused-imports/no-unused-imports` 一条规则,`yarn lint` 目前可正常运行(仅存在少量 warning,无 error)。 -If your proxy needs password, use: +## V1 验收状态 -```shell --e PROXY_URL="http://127.0.0.1:7890 user pass" +```text +V1 Final Acceptance: +PASS WITH KNOWN LIMITATIONS ``` -If enable MCP, use: +冻结版本: -``` -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=your-password \ - -e ENABLE_MCP=true \ - yidadaa/chatgpt-next-web -``` - -### Shell +```text +Frontend: +f1c5c8af56615152513ab3d41081cd48ed434301 -```shell -bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) +Backend: +4dfb074a48f236b2b3fa20dc7fe88d4e562ff073 ``` -## Synchronizing Chat Records (UpStash) +前端验收覆盖:Conversation 列表、Draft、首次发送、SSE streaming、Refresh history、Processing refresh、Delete、Archive、Restore、Cancel、Failed 展示、Cancelled 展示、本地存储清理,以及 Real Gemini E2E。 -| [简体中文](./docs/synchronise-chat-logs-cn.md) | [English](./docs/synchronise-chat-logs-en.md) | [Italiano](./docs/synchronise-chat-logs-es.md) | [日本語](./docs/synchronise-chat-logs-ja.md) | [한국어](./docs/synchronise-chat-logs-ko.md) +## 已知限制 -## Documentation +- **Backend 依赖**:没有 personChat Backend,前端无法聊天。 +- **共享 Provider 账号**:V1.4 已有 REGISTERED 用户与 User.id ownership/quota 隔离,但所有用户共享 Backend、Browser Profile 与 Google/Gemini 登录态;Browser Pool 与多 Gemini Account 不在本版本。 +- **发布闸门**:**READY FOR PRODUCTION RELEASE REVIEW, NOT DEPLOYED** —— 发布前仍需 backup、真实 DB 指纹复核、`prisma migrate deploy`、production env/Nginx 核验、计划中的真实 Gemini smoke 与 post-deploy auth/browser smoke。 +- **Gemini 人工登录**:Gemini 登录态由 Backend Browser Profile 提供,前端无法处理登录。 +- **Gemini DOM 依赖**:Gemini Web 页面改版会影响 Backend 自动化。 +- **单实例**:Backend 当前为单实例架构。 +- **RATE_LIMITED**:该错误码当前无可靠的真实检测。 -> Please go to the [docs][./docs] directory for more documentation instructions. +## 安全说明 -- [Deploy with cloudflare (Deprecated)](./docs/cloudflare-pages-en.md) -- [Frequent Ask Questions](./docs/faq-en.md) -- [How to add a new translation](./docs/translation.md) -- [How to use Vercel (No English)](./docs/vercel-cn.md) -- [User Manual (Only Chinese, WIP)](./docs/user-manual-cn.md) +Frontend **不**保存: -## Translation +- Google Cookie +- Gemini Token +- Backend 敏感凭证 +- 管理员密码(不写 localStorage / sessionStorage,不进 URL query,不进 console.log) +- Session Cookie 内容(HttpOnly,由浏览器自动携带;前端不读 Cookie 判身份) +- 聊天主数据副本 -If you want to add a new translation, read this [document](./docs/translation.md). +本地只剩**可丢弃**数据:UI 配置、面具 / Prompt 资源、未发送草稿。清掉它们不影响任何权威数据,身份与聊天历史一律回服务端重取。 -## Donation +不应重新开启: -[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) +- API Key 输入 +- WebDAV +- ShareGPT +- 旧 Provider proxy -## Special Thanks +## 相关仓库 -### Contributors +Backend: - - - +```text +https://github.com/hukaiedu/NextChatBack +``` -## LICENSE +## Credits -[MIT](https://opensource.org/license/mit/) +Frontend based on [NextChat](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web). diff --git a/README_CN.md b/README_CN.md index f4c441ad006..8a14b78caee 100644 --- a/README_CN.md +++ b/README_CN.md @@ -309,7 +309,7 @@ BASE_URL=https://b.nextweb.fun/api/proxy ### 本地开发 -1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT; +1. 安装 nodejs 24 和 yarn,具体细节请询问 ChatGPT; 2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署! 3. 如果你想本地部署,请使用 `yarn install && yarn build && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。 diff --git a/README_JA.md b/README_JA.md index 917cc1262c0..2fc79ece6af 100644 --- a/README_JA.md +++ b/README_JA.md @@ -254,7 +254,7 @@ OPENAI_API_KEY= ### ローカル開発 -1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。 +1. Node.js 24 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。 2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。 3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。 diff --git a/README_KO.md b/README_KO.md index 40ba5a6a1c4..06e1256efd5 100644 --- a/README_KO.md +++ b/README_KO.md @@ -383,7 +383,7 @@ SiliconFlow API URL입니다. ## 요구 사항 (Requirements) -NodeJS >= 18, Docker >= 20 +NodeJS >= 24, Docker >= 20 ## 개발 (Development) diff --git a/app/admin/admin.module.scss b/app/admin/admin.module.scss new file mode 100644 index 00000000000..d20618659a9 --- /dev/null +++ b/app/admin/admin.module.scss @@ -0,0 +1,49 @@ +.console { + min-height: 100vh; + overflow-y: auto; + box-sizing: border-box; + background-color: var(--second); + + .inner { + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: 24px 20px 40px; + box-sizing: border-box; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .title { + font-size: 20px; + font-weight: bold; + } + + .sections { + display: flex; + flex-direction: column; + gap: 16px; + } + + .actions { + display: flex; + align-items: center; + gap: 10px; + } + + .value { + max-width: 260px; + font-size: 12px; + text-align: right; + opacity: 0.75; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 00000000000..349211b1dac --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { useAuthStore, LoginError } from "../../store/auth"; +import { getAuthSession } from "../../client/backend-api"; +import Locale from "../../locales"; +import styles from "../../components/auth-gate.module.scss"; + +/** + * V1.3-C §16/§17:独立的管理员入口。 + * + * - 已是 ADMIN → 直接进 /admin + * - ANONYMOUS / 未认证 → 显示管理员密码表单(**不**先创建匿名身份) + * - 已有匿名身份时,提交前显示身份切换提示(§18) + * + * 密码只存在于 form state 与一次 POST 请求里(§69),不写 storage / URL / console。 + */ +export default function AdminLoginPage() { + const router = useRouter(); + const adminLogin = useAuthStore((state) => state.adminLogin); + const adminLoginError = useAuthStore((state) => state.adminLoginError); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [probing, setProbing] = useState(true); + /** 当前浏览器已有的身份;null = 未认证 / 探测失败 */ + const [existing, setExisting] = useState(null); + + useEffect(() => { + let alive = true; + void getAuthSession() + .then((session) => { + if (!alive) return; + if (session.authenticated && session.userType === "ADMIN") { + // 不解除占位:避免闪一下密码表单再跳走 + router.replace("/admin"); + return; + } + setProbing(false); + setExisting(session.authenticated ? session.userType ?? null : null); + }) + .catch(() => { + if (alive) setProbing(false); + }); + return () => { + alive = false; + }; + }, [router]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (submitting || !password) return; + setSubmitting(true); + void adminLogin(password) + .then((ok) => { + // §69:提交完成后立刻清理 form state + setPassword(""); + // FIX-02A:后端删掉旧匿名 Session 并覆盖同名 Cookie,且匿名 Conversation 不转给 + // ADMIN —— 必须整页换文档,才能让旧匿名 chat store / 消息缓存 / SSE 一起消失。 + if (ok) window.location.replace("/admin"); + }) + .finally(() => setSubmitting(false)); + }; + + const errorText = (error: LoginError): string => { + if (error.code === "AUTH_RATE_LIMITED") { + return Locale.AdminConsole.Error.AUTH_RATE_LIMITED( + error.retryAfterSeconds ?? 0, + ); + } + if (error.code === "AUTH_INVALID_CREDENTIALS") { + return Locale.AdminConsole.Error.AUTH_INVALID_CREDENTIALS; + } + if (error.code === "ADMIN_UNAVAILABLE") { + return Locale.AdminConsole.Error.ADMIN_UNAVAILABLE; + } + return Locale.AdminConsole.Error.NETWORK_ERROR; + }; + + if (probing) { + return
; + } + + return ( +
+
+
{Locale.AdminConsole.LoginTitle}
+ {existing === "ANONYMOUS" && ( +
+ {Locale.AdminConsole.SwitchWarning} +
+ )} + setPassword(event.currentTarget.value)} + /> + {adminLoginError && ( +
{errorText(adminLoginError)}
+ )} + +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 00000000000..87a5314f050 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +import ReloadIcon from "../icons/reload.svg"; +import { + AdminProviderState, + AdminProviderStatus, + getAdminProviderStatus, + openAdminProvider, + restartAdminProvider, + revokeAllAdminSessions, +} from "../client/admin-api"; +import { + AuthSessionInfo, + BackendApiError, + getAuthSession, +} from "../client/backend-api"; +import { useAuthStore } from "../store/auth"; +import { AdminBrowserPanel } from "../components/browser-status"; +import { IconButton } from "../components/button"; +import { List, ListItem, showConfirm, showToast } from "../components/ui-lib"; +import Locale from "../locales"; +import styles from "./admin.module.scss"; + +/** + * V1.3-C §20/§21:管理控制台。 + * + * 守卫只做 GET /auth/session 探测(§46 真相来自后端),不走匿名 bootstrap(§17): + * 打开 /admin 不该顺带建一个访客身份。非 ADMIN 一律回 /admin/login。 + */ +function AdminConsolePage() { + const router = useRouter(); + const clearIdentity = useAuthStore((state) => state.clearIdentity); + const logout = useAuthStore((state) => state.logout); + const [identity, setIdentity] = useState(null); + const [busy, setBusy] = useState<"revoke" | "logout" | null>(null); + + useEffect(() => { + let alive = true; + void getAuthSession() + .then((session) => { + if (!alive) return; + if (session.authenticated && session.userType === "ADMIN") { + setIdentity(session); + } else { + router.replace("/admin/login"); + } + }) + .catch(() => { + if (alive) router.replace("/admin/login"); + }); + return () => { + alive = false; + }; + }, [router]); + + /** + * FIX-02B:Cookie 在全 Tab 间共享,另一个 Tab 的 logout / revoke-all 会让本 Tab + * 的管理员身份失效。页面重新活跃时轻量复核一次 —— 不轮询、不建匿名身份。 + */ + const reprobeInFlight = useRef(false); + const leftConsole = useRef(false); + useEffect(() => { + const reprobe = () => { + if (document.visibilityState !== "visible") return; + if (reprobeInFlight.current || leftConsole.current) return; + reprobeInFlight.current = true; + void getAuthSession() + .then((session) => { + if (session.authenticated && session.userType === "ADMIN") { + setIdentity(session); + return; + } + leftConsole.current = true; + // 身份已不属于管理员:清本地身份,回登录页,绝不自动建匿名身份(§9) + clearIdentity(); + router.replace("/admin/login"); + }) + .catch(() => { + // 网络抖动不把管理员踢出:下次再活跃时重新复核 + }) + .finally(() => { + reprobeInFlight.current = false; + }); + }; + document.addEventListener("visibilitychange", reprobe); + window.addEventListener("focus", reprobe); + return () => { + document.removeEventListener("visibilitychange", reprobe); + window.removeEventListener("focus", reprobe); + }; + }, [clearIdentity, router]); + + const onRevokeAll = async () => { + if (busy) return; + if (!(await showConfirm(Locale.AdminConsole.Sessions.RevokeAllConfirm))) { + return; + } + setBusy("revoke"); + try { + const { revoked } = await revokeAllAdminSessions(); + // 后端删库成功即已清 Cookie:清本地身份但不重新引导(§42) + clearIdentity(); + showToast(Locale.AdminConsole.Sessions.RevokeAllSuccess(revoked)); + router.replace("/admin/login"); + } catch (error) { + console.error("[Admin] 吊销全部登录状态失败", error); + if (error instanceof BackendApiError && error.status === 401) { + // Session 其实已经失效:同样按已登出收口,不再重试 + clearIdentity(); + router.replace("/admin/login"); + return; + } + showToast(Locale.AdminConsole.Sessions.RevokeAllFailed); + setBusy(null); + } + }; + + const onLogout = async () => { + if (busy) return; + if (!(await showConfirm(Locale.AdminConsole.Logout.Confirm))) return; + setBusy("logout"); + if (await logout()) { + router.replace("/admin/login"); + return; + } + showToast(Locale.AdminConsole.Logout.Failed); + setBusy(null); + }; + + if (!identity) { + return
; + } + + const userType = identity.userType ?? "ANONYMOUS"; + + return ( +
+
+
+
{Locale.AdminConsole.Title}
+ router.push("/")} + /> +
+
+ + +
+ {Locale.AdminConsole.Session.UserType[userType]} +
+
+ +
+ {identity.expiresAt + ? new Date(identity.expiresAt).toLocaleString() + : Locale.Browser.Empty} +
+
+
+ + + + + + + +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +} + +const PROVIDER_STATE_LABEL: Record = { + STOPPED: Locale.AdminConsole.Provider.State.STOPPED, + STARTING: Locale.AdminConsole.Provider.State.STARTING, + LOGIN_REQUIRED: Locale.AdminConsole.Provider.State.LOGIN_REQUIRED, + READY: Locale.AdminConsole.Provider.State.READY, + BUSY: Locale.AdminConsole.Provider.State.BUSY, + ERROR: Locale.AdminConsole.Provider.State.ERROR, +}; + +/** Admin 面显示后端原始错误码(§36):运维需要的是拒绝原因,不是 Public 的通用文案 */ +function providerErrorText(error: unknown): string { + return error instanceof BackendApiError + ? `${error.code}: ${error.message}` + : String(error); +} + +/** + * Provider 运维面板:状态 + 打开 + 重启(canonical /admin/provider/*)。 + * 状态是服务端事实,进面板读一次,操作后直接用后端返回值刷新,不额外轮询 + * (浏览器面板已有自己的轮询,两个面板不叠两份定时器)。 + */ +function AdminProviderSection() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + + useEffect(() => { + let alive = true; + void getAdminProviderStatus() + .then((next) => { + if (alive) setStatus(next); + }) + .catch((error) => { + if (alive) showToast(providerErrorText(error)); + }); + return () => { + alive = false; + }; + }, []); + + const run = async ( + call: () => Promise, + successText?: string, + ) => { + if (busyRef.current) return; + busyRef.current = true; + setBusy(true); + try { + setStatus(await call()); + if (successText) showToast(successText); + } catch (error) { + console.error("[Admin] Provider 操作失败", error); + showToast(providerErrorText(error)); + } finally { + busyRef.current = false; + setBusy(false); + } + }; + + const onRestart = async () => { + if (!(await showConfirm(Locale.AdminConsole.Provider.RestartConfirm))) { + return; + } + await run( + restartAdminProvider, + Locale.AdminConsole.Provider.RestartSuccess, + ); + }; + + return ( + + +
+ {status + ? PROVIDER_STATE_LABEL[status.status] + : Locale.AdminConsole.Provider.Unknown} +
+
+ +
+ } + bordered + disabled={busy} + title={Locale.Browser.Actions.Refresh} + onClick={() => void run(getAdminProviderStatus)} + /> + + void run( + openAdminProvider, + Locale.AdminConsole.Provider.OpenSuccess, + ) + } + /> + void onRestart()} + /> +
+
+
+ ); +} + +export default AdminConsolePage; 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/auth.ts b/app/api/auth.ts index 8c78c70c865..8cd3ffd9d7d 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -4,7 +4,7 @@ import md5 from "spark-md5"; import { ACCESS_CODE_PREFIX, ModelProvider } from "../constant"; function getIP(req: NextRequest) { - let ip = req.ip ?? req.headers.get("x-real-ip"); + let ip = req.headers.get("x-real-ip"); const forwardedFor = req.headers.get("x-forwarded-for"); if (!ip && forwardedFor) { 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/backend-api/[...path]/route.ts b/app/backend-api/[...path]/route.ts new file mode 100644 index 00000000000..043410c2e96 --- /dev/null +++ b/app/backend-api/[...path]/route.ts @@ -0,0 +1,175 @@ +const DEFAULT_BACKEND_ORIGIN = "http://127.0.0.1:3010"; + +const REQUEST_HOP_BY_HOP_HEADERS = new Set([ + "connection", + "content-length", + "forwarded", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +const RESPONSE_HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +type HeadersWithSetCookie = Headers & { + getSetCookie?: () => string[]; +}; + +type ProxyContext = { + params: Promise<{ path: string[] }>; +}; + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function getBackendOrigin(): string { + const rawOrigin = + process.env.BACKEND_ORIGIN?.trim() || DEFAULT_BACKEND_ORIGIN; + const origin = new URL(rawOrigin); + + if ( + (origin.protocol !== "http:" && origin.protocol !== "https:") || + origin.username || + origin.password || + origin.search || + origin.hash + ) { + throw new Error("BACKEND_ORIGIN must be an absolute HTTP(S) origin"); + } + + return origin.toString().replace(/\/$/, ""); +} + +function buildBackendUrl(request: Request, path: string[]): URL { + const origin = new URL(`${getBackendOrigin()}/`); + const backendPath = [ + "api", + ...path.map((segment) => encodeURIComponent(segment)), + ].join("/"); + const target = new URL(backendPath, origin); + target.search = new URL(request.url).search; + return target; +} + +function copyRequestHeaders(request: Request): Headers { + const headers = new Headers(); + + for (const [name, value] of request.headers.entries()) { + const normalizedName = name.toLowerCase(); + if ( + REQUEST_HOP_BY_HOP_HEADERS.has(normalizedName) || + normalizedName === "x-forwarded-for" || + normalizedName === "x-forwarded-host" || + normalizedName === "x-forwarded-proto" || + normalizedName === "x-real-ip" + ) { + continue; + } + headers.set(name, value); + } + + return headers; +} + +function copyResponseHeaders(response: Response): Headers { + const headers = new Headers(); + const sourceHeaders = response.headers as HeadersWithSetCookie; + const setCookies = sourceHeaders.getSetCookie?.() ?? []; + + for (const [name, value] of response.headers.entries()) { + const normalizedName = name.toLowerCase(); + if ( + RESPONSE_HOP_BY_HOP_HEADERS.has(normalizedName) || + normalizedName === "set-cookie" + ) { + continue; + } + headers.append(name, value); + } + + if (setCookies.length > 0) { + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + } else { + const combinedSetCookie = response.headers.get("set-cookie"); + if (combinedSetCookie) { + headers.set("set-cookie", combinedSetCookie); + } + } + + return headers; +} + +async function proxy( + request: Request, + context: ProxyContext, +): Promise { + const { path } = await context.params; + + let target: URL; + try { + target = buildBackendUrl(request, path); + } catch { + return jsonResponse({ error: "Backend proxy is not configured" }, 500); + } + + const fetchInit: RequestInit & { duplex?: "half" } = { + method: request.method, + headers: copyRequestHeaders(request), + redirect: "manual", + cache: "no-store", + signal: request.signal, + }; + + if (request.body && request.method !== "GET" && request.method !== "HEAD") { + fetchInit.body = request.body; + fetchInit.duplex = "half"; + } + + try { + const response = await fetch(target.toString(), fetchInit); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: copyResponseHeaders(response), + }); + } catch (error) { + if (request.signal.aborted) { + throw error; + } + + return jsonResponse({ error: "Backend unavailable" }, 502); + } +} + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const fetchCache = "force-no-store"; + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const OPTIONS = proxy; +export const HEAD = proxy; diff --git a/app/client/admin-api.ts b/app/client/admin-api.ts new file mode 100644 index 00000000000..76359f65d05 --- /dev/null +++ b/app/client/admin-api.ts @@ -0,0 +1,103 @@ +/** + * V1.3-C §22/§41:Admin Console 的 API 客户端。 + * + * 与 Public 客户端(app/client/backend-api.ts)刻意分层: + * - 这里的类型含运维内部字段(profileDir / providerLoggedIn / headless / browserType / lastError), + * 只能被 /admin 页面消费,不得进入普通聊天 UI。 + * - 所有请求走 canonical `/backend-api/admin/*`;旧 `/browser/*`、`/provider/status` 等 + * 兼容 alias 已随 V1.3-C 从后端删除,前端零引用。 + */ + +import { callBackend } from "./backend-api"; + +/** 后端浏览器实例的生命周期状态 */ +export type AdminBrowserState = + | "RUNNING" + | "STARTING" + | "RESTARTING" + | "STOPPED" + | "FAILED"; + +export interface AdminBrowserError { + code: string; + message?: string | null; +} + +/** GET /api/admin/browser/status 的负载(BrowserStatusSnapshot) */ +export interface AdminBrowserStatus { + state: AdminBrowserState; + /** "chromium" */ + browserType: string; + headless: boolean; + /** 持久化 Profile 目录(运维字段) */ + profileDir: string; + startedAt: string | null; + uptimeMs: number | null; + /** Provider 登录态;null = 后端未探测 */ + providerLoggedIn: boolean | null; + /** 正在 PENDING / PROCESSING / CANCELLING 的 Request 数 */ + activeRequests: number; + lastError: AdminBrowserError | null; + /** 后端生成该快照的时间(ISO) */ + observedAt: string; +} + +export function getAdminBrowserStatus(): Promise { + return callBackend("/admin/browser/status"); +} + +/** POST /api/admin/browser/restart:耗时较长(关旧实例 + 起新实例 + 打开 Provider 页面) */ +export function restartAdminBrowser(): Promise { + return callBackend("/admin/browser/restart", { + method: "POST", + }); +} + +/** + * POST /api/admin/sessions/revoke-all:吊销调用者(ADMIN)的全部 Session(含当前这条)。 + * 后端删库成功后会清 Cookie;前端收到成功后必须清本地认证状态并回到 /admin/login。 + */ +export function revokeAllAdminSessions(): Promise<{ revoked: number }> { + return callBackend<{ revoked: number }>("/admin/sessions/revoke-all", { + method: "POST", + }); +} + +/** + * Provider(Browser Manager 里那个 Provider 页面)的生命周期状态。 + * 与浏览器实例状态分开:浏览器可以已在跑但 Provider 页面还没打开/未登录。 + */ +export type AdminProviderState = + | "STOPPED" + | "STARTING" + | "LOGIN_REQUIRED" + | "READY" + | "BUSY" + | "ERROR"; + +export interface AdminProviderStatus { + /** 后端回显的 Provider 标识(GEMINI_WEB),仅 Admin 面可见 */ + provider: string; + status: AdminProviderState; +} + +export function getAdminProviderStatus(): Promise { + return callBackend("/admin/provider/status"); +} + +/** POST /api/admin/provider/open:启动 Browser Manager 并打开/聚焦 Provider 页面 */ +export function openAdminProvider(): Promise { + return callBackend("/admin/provider/open", { + method: "POST", + }); +} + +/** + * POST /api/admin/provider/restart:关闭 Context 后用同一 Profile 重启。 + * 有 Request 在飞时后端会拒绝(运维不该顺手打断生成)。 + */ +export function restartAdminProvider(): Promise { + return callBackend("/admin/provider/restart", { + method: "POST", + }); +} diff --git a/app/client/backend-api.ts b/app/client/backend-api.ts new file mode 100644 index 00000000000..e0c9bb4345e --- /dev/null +++ b/app/client/backend-api.ts @@ -0,0 +1,656 @@ +/** + * 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"; + +/** + * V1.3-C:与 Backend B3-2 Public DTO 逐字段对齐的会话形状。 + * 后端已不再返回 userId / provider / providerConversationUrl / deletedAt(Internal-only), + * 前端不得再声明或依赖它们。 + */ +export interface BackendConversation { + id: string; + title: string; + status: ConversationStatus | "DELETED"; + /** M4:会话维度的模型偏好;null = 未指定(默认模型) */ + preferredModelKey: string | null; + createdAt: string; + updatedAt: string; +} + +/** M4:GET /api/provider/models 返回的目录项(字段与后端 GeminiModelOption 对应) */ +export interface BackendModelOption { + key: string; + label: string; + /** 页面当前选中的模型(页面状态,与会话偏好无关) */ + selected: boolean; + disabled: boolean; +} + +export interface BackendModelCatalog { + models: BackendModelOption[]; + currentModelKey: 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; + /** + * I3.5:消息携带的图片份数(原图字节从未持久化,只留份数)。 + * B3-2 起是 Public DTO 的固定字段(后端始终返回)。 + */ + attachmentCount: number; +} + +export interface BackendRequest { + id: string; + conversationId: string; + userMessageId: string; + assistantMessageId: string; + status: BackendRequestStatus; + errorCode: string | null; + errorMessage: string | null; + /** V1.2 I3.5:历史图片份数判据(Public DTO 固定字段) */ + attachmentCount: number; + createdAt: string; + updatedAt: string; +} + +export interface SendMessageResult { + request: BackendRequest; + userMessage: BackendMessage; + assistantMessage: BackendMessage; + deduplicated: boolean; +} + +/** I3:POST /messages 附件项(data = 图片 dataURL,字节只在请求体内存活) */ +export interface SendMessageAttachment { + name: string; + mimeType: string; + data: string; +} + +/** 后端统一错误信封 `{ error: { code, message, requestId } }` */ +export class BackendApiError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: number, + /** 429 限流响应 Retry-After 头的秒数;其余场景 undefined */ + readonly retryAfterSeconds?: number, + ) { + super(message); + this.name = "BackendApiError"; + } +} + +/** + * 全局 401 回调:业务 API 收到 AUTH_REQUIRED 时触发。 + * 由 auth store 经 setUnauthorizedHandler 注册,避免 store ↔ client 循环依赖。 + * + * V1.3-C §14:回调只做「标记当前 identity 失效 → 重新进入 bootstrap」。 + * **绝不自动重放失败的业务请求** —— 自动重放会换掉 owner、重复发送消息。 + * 调用方(store/组件)自己决定提示与重试。 + */ +let unauthorizedHandler: (() => void) | null = null; + +export function setUnauthorizedHandler(handler: (() => void) | null): void { + unauthorizedHandler = handler; +} + +/** 仅业务 API 的 401 触发身份刷新;auth 端点自身(登录密码错 / anonymous 被拒)不触发(§11.2) */ +function notifyUnauthorizedIfNeeded( + path: string, + status: number, + code: string | undefined, +): void { + if (status === 401 && code === "AUTH_REQUIRED" && !path.startsWith("/auth")) { + unauthorizedHandler?.(); + } +} + +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)}`; +} + +/** + * Public / Admin 两个客户端共用的低层请求器:统一前缀、统一错误信封、统一 401 通知。 + * admin-api.ts 复用它,避免出现第二套错误解析逻辑。 + */ +export async function callBackend( + 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; + notifyUnauthorizedIfNeeded(path, response.status, error?.code); + throw new BackendApiError( + error?.code ?? "NETWORK_ERROR", + error?.message ?? `Request failed with status ${response.status}`, + response.status, + response.status === 429 + ? Number(response.headers?.get?.("Retry-After")) || undefined + : undefined, + ); + } + + 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; + notifyUnauthorizedIfNeeded("/conversations", response.status, error?.code); + 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 callBackend("/conversations", { + method: "POST", + body: title ? { title } : {}, + }); +} + +export function getConversation(id: string): Promise { + return callBackend( + `/conversations/${encodeURIComponent(id)}`, + ); +} + +export function patchConversation( + id: string, + patch: { + title?: string; + status?: ConversationStatus; + /** M4:显式 null = 清除偏好;undefined = 不动偏好(与后端 PATCH 语义一致) */ + preferredModelKey?: string | null; + }, +): Promise { + return callBackend( + `/conversations/${encodeURIComponent(id)}`, + { + method: "PATCH", + body: patch, + }, + ); +} + +export function deleteConversation(id: string): Promise { + return callBackend(`/conversations/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +/** PAG-2:GET messages 分页返回(旧→新 items + 下一页游标 + 会话消息总数) */ +export interface BackendMessagePage { + items: BackendMessage[]; + nextCursor: string | null; + totalCount: number; +} + +/** + * GET /api/conversations/:id/messages(PAG-2 分页版)。 + * + * 必须 raw fetch 而不是 call:call 只返回 payload.data,拿不到 + * meta.nextCursor / meta.totalCount。错误语义与 listConversations 一致。 + */ +export async function listMessages( + conversationId: string, + page: { limit?: number; cursor?: string | null } = {}, +): Promise { + const query = new URLSearchParams({ limit: String(page.limit ?? 50) }); + if (page.cursor) query.set("cursor", page.cursor); + const path = `/conversations/${encodeURIComponent(conversationId)}/messages`; + const response = await fetch(`${PREFIX}${path}?${query.toString()}`); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const error = ( + payload as { error?: { code?: string; message?: string } } | null + )?.error; + notifyUnauthorizedIfNeeded(path, response.status, error?.code); + throw new BackendApiError( + error?.code ?? "NETWORK_ERROR", + error?.message ?? `Request failed with status ${response.status}`, + response.status, + ); + } + const body = payload as { + data: BackendMessage[]; + meta?: { nextCursor?: string | null; totalCount?: number }; + }; + return { + items: body.data ?? [], + nextCursor: body.meta?.nextCursor ?? null, + totalCount: body.meta?.totalCount ?? 0, + }; +} + +export function sendMessage( + conversationId: string, + content: string, + idempotencyKey: string, + modelKey?: string, + attachments?: SendMessageAttachment[], +): Promise { + return callBackend( + `/conversations/${encodeURIComponent(conversationId)}/messages`, + { + method: "POST", + body: { + content, + ...(modelKey !== undefined ? { modelKey } : {}), + // I3:无附件(含 [])一律缺省字段,与旧纯文本请求逐字节兼容 + ...(attachments?.length ? { attachments } : {}), + }, + headers: { "Idempotency-Key": idempotencyKey }, + }, + ); +} + +/** POST /api/requests/:id/cancel(prd §8.9) */ +export function cancelRequest(requestId: string): Promise { + return callBackend( + `/requests/${encodeURIComponent(requestId)}/cancel`, + { method: "POST" }, + ); +} + +/** GET /api/provider/models(M4);Provider 非就绪时抛 BackendApiError */ +export function listProviderModels(): Promise { + return callBackend("/provider/models"); +} + +/** V1.3:后端身份类型;REGISTERED 目前不会产生,保留以兼容未来 */ +export type BackendUserType = "ANONYMOUS" | "ADMIN" | "REGISTERED"; + +/** GET /api/auth/session 的负载(§四) */ +export interface AuthSessionInfo { + authenticated: boolean; + /** V1.3-B3-2:身份类型;未认证分支后端不返回该字段,消费侧按 null 兜底 */ + userType?: BackendUserType | null; + /** Session 过期时间(ISO);未认证或 disabled 模式为 null */ + expiresAt: string | null; + /** + * V1.4 U4 §6:注册用户的展示登录名(后端 authenticated 面第四键)。 + * §7 双版本兼容:旧后端不返回该键,经 normalizeAuthSession 归一后恒为 string | null。 + */ + username?: string | null; +} + +/** + * V1.4 U4 §7:把后端 session 负载收敛成前端唯一形状 —— 缺 username 的旧响应归一成 + * null,而不是让 undefined 泄漏进 store 去制造分支崩溃。 + */ +function normalizeAuthSession(session: AuthSessionInfo): AuthSessionInfo { + return { ...session, username: session.username ?? null }; +} + +/** GET /api/auth/session:永不 401,启动探测与 SSE 重连探测共用 */ +export function getAuthSession(): Promise { + return callBackend("/auth/session").then( + normalizeAuthSession, + ); +} + +/** + * POST /api/auth/anonymous(V1.3-B2 §8):无/无效 Cookie → 建匿名 User+Session 并 Set-Cookie。 + * 幂等:Cookie 已有效时直接返回既有身份。DISABLED Cookie → 401 AUTH_REQUIRED(不新建 User)。 + */ +export function bootstrapAnonymous(): Promise { + return callBackend("/auth/anonymous", { + method: "POST", + }).then(normalizeAuthSession); +} + +/** + * POST /api/auth/login:**ADMIN 专用**密码登录。 + * V1.4 U2 冻结后普通注册用户走 userLogin,这个端点永远不接受 username。 + * 密码错 → 401,限流 → 429(信封 code + retryAfterSeconds)。 + */ +export function login(password: string): Promise { + return callBackend("/auth/login", { + method: "POST", + body: { password }, + }).then(normalizeAuthSession); +} + +/** + * POST /api/auth/register(U4 §9):把**当前匿名身份**原地升级为 REGISTERED。 + * body 只有 username + password;确认密码只活在页面前端,永不发给后端。 + * 409 AUTH_USERNAME_ALREADY_TAKEN / 409 AUTH_IDENTITY_NOT_ANONYMOUS / + * 429 AUTH_RATE_LIMITED / 503 SERVICE_BUSY。 + */ +export function registerUser( + username: string, + password: string, +): Promise { + return callBackend("/auth/register", { + method: "POST", + body: { username, password }, + }).then(normalizeAuthSession); +} + +/** + * POST /api/auth/user/login(U4 §10):Registered 账号登录,与 Admin 的 + * POST /auth/login 是两条永不相交的路径。口令错恒 401 AUTH_INVALID_CREDENTIALS。 + */ +export function userLogin( + username: string, + password: string, +): Promise { + return callBackend("/auth/user/login", { + method: "POST", + body: { username, password }, + }).then(normalizeAuthSession); +} + +/** + * POST /api/auth/password/change(U4 §11):成功后后端已轮换当前 Session、 + * 撤销该账号其它所有 Session,响应就是**新的**当前身份负载。 + */ +export function changePassword( + currentPassword: string, + newPassword: string, +): Promise { + return callBackend("/auth/password/change", { + method: "POST", + body: { currentPassword, newPassword }, + }).then(normalizeAuthSession); +} + +/** + * POST /api/auth/sessions/revoke-all(U4 §12):撤销**自己**的全部 Session, + * 含当前这一条;envelope 由 callBackend 统一解包,这里只拿 data.revoked。 + */ +export function revokeAllSessions(): Promise<{ revoked: number }> { + return callBackend<{ revoked: number }>("/auth/sessions/revoke-all", { + method: "POST", + }); +} + +/** POST /api/auth/logout:幂等 204,清 Session Cookie */ +export function logout(): Promise { + return callBackend("/auth/logout", { 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]; + +/** + * §8.3:transport error 后的 session probe。多个 SSE 同时断开时共享同一次探测 + * (in-flight 合并);探测自身网络失败返回 null(第三态:不修改认证状态)。 + */ +let authProbeInFlight: Promise | null = null; + +function probeAuthSession(): Promise { + authProbeInFlight ??= getAuthSession() + .catch(() => null) + .finally(() => { + authProbeInFlight = null; + }); + return authProbeInFlight; +} + +/** + * 订阅一条 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; + } + // 传输层错误(含 401 拒绝——EventSource 不暴露状态码,§8.2/§8.3): + // 先探测 session,三态——失效则停连并全局登出;有效或探测失败则维持既有重连。 + void probeAuthSession().then((session) => { + if (closedByClient || finished) return; + if (session && !session.authenticated) { + closedByClient = true; + if (retryTimer) clearTimeout(retryTimer); + closeSource(); + unauthorizedHandler?.(); + return; + } + if (source?.readyState === EventSource.CLOSED) { + // 服务端已结束响应(多为终态后主动 close):重连一次读最终状态即可自愈 + scheduleReconnect(); + } + }); + }); + } + + open(); + + return { + close() { + closedByClient = true; + if (retryTimer) clearTimeout(retryTimer); + closeSource(); + }, + }; +} diff --git a/app/components/account.tsx b/app/components/account.tsx new file mode 100644 index 00000000000..ed105c3102e --- /dev/null +++ b/app/components/account.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +import { IconButton } from "./button"; +import { + List, + ListItem, + PasswordInput, + showConfirm, + showToast, +} from "./ui-lib"; +import { LoginError, useAuthStore } from "../store/auth"; +import { credentialErrorText, isPasswordUsable } from "../utils/credential"; +import { leaveIdentityAndReload } from "../utils/identity"; +import Locale from "../locales"; +import gateStyles from "./auth-gate.module.scss"; + +/** + * V1.4 U4 §37–§43:账号区,挂在 Settings 里(现成的次级面板,不重构侧边栏)。 + * + * - 访客:登录 / 注册入口(§38) + * - 注册用户:@username + 退出 为一级;修改密码 / 退出所有设备 在次级(§39) + * - 管理员:只保留既有控制台入口,绝不伪装成注册用户(§40 / UI-ADMIN-01) + * + * 去 `/login`、`/register` 用客户端跳转,这样返回聊天时当前文档里的会话与 + * 未提交输入都还在;只有真正换主体(退出 / 退出所有设备)才整页换文档。 + */ +export function AccountSection() { + const router = useRouter(); + const userType = useAuthStore((state) => state.userType); + const accountName = useAuthStore((state) => state.username); + const logout = useAuthStore((state) => state.logout); + const changePassword = useAuthStore((state) => state.changePassword); + const revokeAllSessions = useAuthStore((state) => state.revokeAllSessions); + const [busy, setBusy] = useState<"logout" | "change" | "revoke" | null>(null); + const [showChange, setShowChange] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + /** §85:错误只活在这个面板,不进 store、不跨身份 */ + const [error, setError] = useState(null); + + const t = Locale.Account; + + const onLogout = async () => { + if (busy) return; + setBusy("logout"); + setError(null); + if (await logout()) { + // §44:后端 204 之后才收本地;回首页由 AuthGate 建新的访客身份(§45) + leaveIdentityAndReload("/"); + return; + } + const failure: LoginError | null = useAuthStore.getState().logoutError; + setError(failure ? credentialErrorText(failure) : t.Error.GENERIC); + setBusy(null); + }; + + const onChangePassword = async () => { + if (busy) return; + if (!isPasswordUsable(newPassword)) { + setError(t.PasswordTooShort); + return; + } + setBusy("change"); + setError(null); + const result = await changePassword(currentPassword, newPassword); + if (!result.ok) { + // §76 AUTH-PWD-05:失败留在表单上,不跳转也不清聊天 + setError(credentialErrorText(result.error)); + setBusy(null); + return; + } + // §42:同一 userId,当前设备保持登录,其它设备由后端撤销 —— 聊天视图一点不动 + setCurrentPassword(""); + setNewPassword(""); + setShowChange(false); + setBusy(null); + showToast(t.PasswordChanged); + }; + + const onRevokeAll = async () => { + if (busy) return; + if (!(await showConfirm(t.RevokeAllConfirm))) return; + setBusy("revoke"); + setError(null); + const result = await revokeAllSessions(); + if (!result.ok) { + setError(credentialErrorText(result.error)); + setBusy(null); + return; + } + // §43:当前 Session 也在撤销之列 → 清视图 + 换文档,首页重新建访客身份 + leaveIdentityAndReload("/"); + }; + + const errorRow = error ? ( +
+ {error} +
+ ) : null; + + if (userType === "REGISTERED") { + return ( + + + + + + { + setShowChange((visible) => !visible); + setError(null); + }} + /> + + {showChange && ( + <> + + + setCurrentPassword(event.currentTarget.value) + } + /> + + + setNewPassword(event.currentTarget.value)} + /> + + + + + + )} + + + + {errorRow} + + ); + } + + if (userType === "ADMIN") { + return ( + + + window.location.assign("/admin")} + /> + + + ); + } + + return ( + + + + router.push("/login")} + /> + + + router.push("/register")} + /> + + {errorRow} + + ); +} diff --git a/app/components/auth-gate.module.scss b/app/components/auth-gate.module.scss new file mode 100644 index 00000000000..ac1875bd730 --- /dev/null +++ b/app/components/auth-gate.module.scss @@ -0,0 +1,58 @@ +.auth-gate { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + + .form { + display: flex; + flex-direction: column; + gap: 12px; + width: 320px; + max-width: 86vw; + padding: 32px; + border-radius: 14px; + background-color: var(--white); + box-shadow: var(--shadow); + box-sizing: border-box; + + .title { + font-size: 20px; + font-weight: bold; + text-align: center; + margin-bottom: 8px; + } + + .input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: var(--border-in-light); + background-color: var(--gray); + font-size: 14px; + box-sizing: border-box; + } + + .error { + color: var(--danger); + font-size: 12px; + line-height: 150%; + } + + .submit { + padding: 10px 0; + border-radius: 10px; + border: none; + background-color: var(--primary); + color: var(--white); + font-size: 14px; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } +} diff --git a/app/components/auth-gate.tsx b/app/components/auth-gate.tsx new file mode 100644 index 00000000000..8f848b20af0 --- /dev/null +++ b/app/components/auth-gate.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { ReactNode, useEffect, useRef } from "react"; + +import { AuthStatus, AuthUserType, useAuthStore } from "../store/auth"; +import { useChatStore } from "../store/chat"; +import Locale from "../locales"; +import styles from "./auth-gate.module.scss"; + +/** + * V1.3-C §5/§6:会话引导门(取代 V1.2 的密码门)。 + * + * 普通用户打开 `/` 不再看到任何密码表单:探测 session,没有有效身份就自动 + * 创建匿名身份,然后直接进入聊天(§5)。 + * + * - unknown / bootstrapping → 占位(避免白屏与旧密码框闪一下) + * - authenticated → 透传 children + * - blocked → DISABLED 等确定性拒绝;中性提示 + 手动 Retry(§10) + * - error → 网络/5xx;提示 + 手动 Retry(§11) + * + * 同时承担 §45 的多标签页身份刷新:tab 重新可见时轻量探测一次 session。 + */ +export function AuthGate(props: { children?: ReactNode }) { + const status = useAuthStore((state) => state.status); + const bootstrap = useAuthStore((state) => state.bootstrap); + const retryBootstrap = useAuthStore((state) => state.retryBootstrap); + const refreshIdentity = useAuthStore((state) => state.refreshIdentity); + + useEffect(() => { + // single-flight 在 store 内保证:StrictMode 双 effect 不会发两次 anonymous(§12/§13) + void bootstrap(); + }, [bootstrap]); + + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible") { + refreshIdentity(); + } + }; + document.addEventListener("visibilitychange", onVisible); + window.addEventListener("focus", onVisible); + return () => { + document.removeEventListener("visibilitychange", onVisible); + window.removeEventListener("focus", onVisible); + }; + }, [refreshIdentity]); + + /** + * §45/§46 FIX-02:身份边界。Cookie 在全 Tab 间共享,所以本 Tab 的内存身份可能 + * 已被另一个 Tab 换掉(ANONYMOUS ↔ ADMIN)、或整个 Session 已被吊销。 + * 光比 userType 不够:新旧匿名身份 userType 相同,且 React 会把「失效 → 重建」 + * 合并成一次渲染 —— 所以要连 identityEpoch 一起比。 + */ + const identityEpoch = useAuthStore((state) => state.identityEpoch); + const userType = useAuthStore((state) => state.userType); + const seenIdentity = useRef<{ + status: AuthStatus; + userType: AuthUserType | null; + epoch: number; + } | null>(null); + useEffect(() => { + const previous = seenIdentity.current; + seenIdentity.current = { status, userType, epoch: identityEpoch }; + // 首次登记(含 StrictMode 重挂)不动作:此刻 store 里的数据就属于当前身份 + if (!previous) return; + + const lost = + previous.status === "authenticated" && status !== "authenticated"; + // 身份被就地换掉:children 一直挂着,没人会重新触发 bootstrap + const swapped = + previous.status === "authenticated" && + status === "authenticated" && + (previous.userType !== userType || previous.epoch !== identityEpoch); + if (!lost && !swapped) return; + + /** + * §47:唯一的例外是「本 Tab 自己刚刚注册成功」—— User.id 没变,聊天还是那一屏数据, + * 重置反而把用户正在看的会话抹掉。标记必须在这里一次性消费掉(§18), + * 于是旁观 Tab 的 ANONYMOUS→REGISTERED(§48)、登录他人、被顶号仍走下面的重置。 + */ + const flagged = useAuthStore.getState().consumeSameSubjectTransition(); + if ( + flagged && + previous.userType === "ANONYMOUS" && + userType === "REGISTERED" + ) { + return; + } + + useChatStore.getState().resetForIdentity(); + // lost 时 children 已卸载,回来的那次挂载由 useLoadData 拉 —— 不在此处重复请求 + if (swapped) void useChatStore.getState().bootstrap(); + }, [status, userType, identityEpoch]); + + if (status === "authenticated") { + return <>{props.children}; + } + + if (status === "blocked" || status === "error") { + return ( +
+
+
+ {status === "blocked" + ? Locale.Bootstrap.Blocked + : Locale.Bootstrap.Error} +
+ +
+
+ ); + } + + // unknown / bootstrapping + return ( +
+
{Locale.Bootstrap.Loading}
+
+ ); +} diff --git a/app/components/auth-layout.module.scss b/app/components/auth-layout.module.scss new file mode 100644 index 00000000000..a30003c90ba --- /dev/null +++ b/app/components/auth-layout.module.scss @@ -0,0 +1,233 @@ +.auth-page { + min-height: 100%; + width: 100%; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + display: flex; + justify-content: center; + padding: 48px 20px 32px; + background: + radial-gradient(circle at 50% -10%, var(--second), transparent 42%), + var(--color-canvas); + color: var(--black); +} + +.auth-layout { + width: min(100%, 420px); + margin: auto; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.brand { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 0 auto 26px; + color: var(--black); + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-mark { + display: inline-flex; + width: 32px; + height: 32px; + overflow: hidden; + border-radius: 10px; +} + +.brand-mark > svg { + width: 100%; + height: 100%; +} + +.card { + box-sizing: border-box; + padding: 32px; + border: 1px solid var(--color-line-strong); + border-radius: 18px; + background: var(--color-surface); + box-shadow: var(--shadow-float); +} + +.title { + margin: 0; + color: var(--black); + font-size: 28px; + line-height: 1.2; + letter-spacing: -0.03em; + text-align: center; +} + +.description { + margin: 10px 0 28px; + color: var(--color-text-soft); + font-size: 14px; + line-height: 1.5; + text-align: center; +} + +.form, +.state-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + color: var(--black); + font-size: 13px; + font-weight: 600; +} + +.input { + width: 100%; + max-width: none; + min-height: 48px; + height: 48px; + box-sizing: border-box; + padding: 0 14px; + border: 1px solid var(--color-line-strong); + border-radius: 12px; + background: var(--color-surface); + color: var(--black); + font-size: 16px; + text-align: left; + transition: border-color 0.18s ease, box-shadow 0.18s ease, + background-color 0.18s ease; +} + +// The legacy global input rule caps text/password inputs at 50%; auth fields +// need the full card width on narrow screens. +.field > .input { + max-width: none; +} + +.input::placeholder { + color: var(--color-text-soft); +} + +.input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(29, 147, 171, 0.16); +} + +.input:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.primary { + width: 100%; + min-height: 48px; + height: 48px; + border: 0; + border-radius: 12px; + background: var(--primary); + color: white; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: filter 0.18s ease, opacity 0.18s ease; +} + +.primary:hover:not(:disabled) { + filter: brightness(0.96); +} + +.primary:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.secondary-row { + color: var(--color-text-soft); + font-size: 13px; + text-align: center; +} + +.link-action { + padding: 0; + border: 0; + background: transparent; + color: var(--primary); + font: inherit; + cursor: pointer; +} + +.link-action:hover { + text-decoration: underline; +} + +.tertiary { + display: flex; + justify-content: center; + width: 100%; + margin-top: 22px; + padding: 4px; + border: 0; + background: transparent; + color: var(--color-text-soft); + font-size: 13px; + cursor: pointer; +} + +.notice { + padding: 10px 12px; + border: 1px solid var(--color-line); + border-radius: 10px; + background: var(--color-surface-soft); + color: var(--color-text-soft); + font-size: 12px; + line-height: 1.5; +} + +.error { + padding: 10px 12px; + border: 1px solid rgba(190, 70, 70, 0.22); + border-radius: 10px; + background: rgba(190, 70, 70, 0.08); + color: rgb(190, 70, 70); + font-size: 12px; + line-height: 1.5; +} + +.footer { + margin-top: 20px; + text-align: center; +} + +@media (max-width: 600px) { + .auth-page { + align-items: flex-start; + padding: 24px 20px; + } + + .auth-layout { + margin: auto 0; + } + + .card { + padding: 24px 20px; + border-radius: 16px; + } + + .title { + font-size: 24px; + } + + .brand { + margin-bottom: 20px; + } +} diff --git a/app/components/auth-layout.tsx b/app/components/auth-layout.tsx new file mode 100644 index 00000000000..41b4c5f84cd --- /dev/null +++ b/app/components/auth-layout.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ReactNode } from "react"; + +import BotIcon from "../icons/bot.svg"; +import styles from "./auth-layout.module.scss"; + +interface AuthLayoutProps { + title: string; + description: string; + children: ReactNode; + footer?: ReactNode; +} + +export function AuthLayout({ + title, + description, + children, + footer, +}: AuthLayoutProps) { + return ( +
+
+
+ + personChat +
+
+

+ {title} +

+

{description}

+ {children} +
+ {footer ?
{footer}
: null} +
+
+ ); +} diff --git a/app/components/browser-status.module.scss b/app/components/browser-status.module.scss new file mode 100644 index 00000000000..828040bf650 --- /dev/null +++ b/app/components/browser-status.module.scss @@ -0,0 +1,102 @@ +.dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.dot-running { + background-color: #2ecc71; +} + +.dot-busy { + background-color: #f1c40f; +} + +.dot-off { + background-color: #9aa0a6; +} + +.dot-error { + background-color: #e74c3c; +} + +.dot-unknown { + background-color: transparent; + border: 1px solid var(--bar-color); +} + +.state-badge { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + white-space: nowrap; +} + +.state-value { + max-width: 220px; + font-size: 12px; + text-align: right; + opacity: 0.75; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +.status-popover { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + background-color: var(--white); + border: var(--border-in-light); + border-radius: 12px; + box-shadow: var(--card-shadow); +} + +.status-popover-hint { + margin-top: 4px; + font-size: 12px; + opacity: 0.7; +} + +.status-popover-rows { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 320px; + overflow-y: auto; +} + +.status-row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 12px; +} + +.status-row-label { + flex-shrink: 0; + opacity: 0.6; +} + +.status-row-value { + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-popover-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} diff --git a/app/components/browser-status.tsx b/app/components/browser-status.tsx new file mode 100644 index 00000000000..ebc36feba3b --- /dev/null +++ b/app/components/browser-status.tsx @@ -0,0 +1,265 @@ +import { useEffect } from "react"; +import clsx from "clsx"; +import styles from "./browser-status.module.scss"; + +import ReloadIcon from "../icons/reload.svg"; +import Locale from "../locales"; +import { AdminBrowserState, AdminBrowserStatus } from "../client/admin-api"; +import { + isUnsupportedBrowserApi, + retainBrowserStatusPolling, + useBrowserStore, +} from "../store/browser"; +import { IconButton } from "./button"; +import { List, ListItem, showConfirm, showToast } from "./ui-lib"; + +/** + * V1.3-C §40/§41:服务端浏览器运维面板。原「Public 设置页 / 聊天头部」入口已移除, + * 本组件只允许被 /admin 控制台挂载 —— 快照含 profileDir / providerLoggedIn 等运维字段。 + */ + +const STATE_LABEL: Record = { + RUNNING: Locale.Browser.State.RUNNING, + STARTING: Locale.Browser.State.STARTING, + RESTARTING: Locale.Browser.State.RESTARTING, + STOPPED: Locale.Browser.State.STOPPED, + FAILED: Locale.Browser.State.FAILED, +}; + +const STATE_HINT: Record = { + RUNNING: Locale.Browser.Hint.RUNNING, + STARTING: Locale.Browser.Hint.STARTING, + RESTARTING: Locale.Browser.Hint.RESTARTING, + STOPPED: Locale.Browser.Hint.STOPPED, + FAILED: Locale.Browser.Hint.FAILED, +}; + +const DOT_CLASS: Record = { + RUNNING: styles["dot-running"], + STARTING: styles["dot-busy"], + RESTARTING: styles["dot-busy"], + STOPPED: styles["dot-off"], + FAILED: styles["dot-error"], +}; + +const UNKNOWN = Locale.Browser.Empty; + +export function browserStateView(status: AdminBrowserStatus | null) { + return { + label: status ? STATE_LABEL[status.state] : Locale.Browser.State.UNKNOWN, + hint: status ? STATE_HINT[status.state] : Locale.Browser.Hint.UNKNOWN, + dotClass: status ? DOT_CLASS[status.state] : styles["dot-unknown"], + }; +} + +function formatDateTime(iso?: string | null): string { + if (!iso) return UNKNOWN; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return UNKNOWN; + return date.toLocaleString(); +} + +function formatClock(ms: number | null): string { + if (ms == null) return UNKNOWN; + return new Date(ms).toLocaleTimeString(); +} + +/** 优先用后端给的 uptimeMs,没有才用 startedAt 与本地时间推算 */ +export function formatUptime( + uptimeMs?: number | null, + startedAt?: string | null, +): string { + let value = uptimeMs; + if (value == null && startedAt) { + const started = new Date(startedAt).getTime(); + value = Number.isNaN(started) ? null : Date.now() - started; + } + if (value == null || Number.isNaN(value) || value < 0) return UNKNOWN; + + const totalSeconds = Math.floor(value / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function formatLoggedIn(loggedIn?: boolean | null): string { + if (loggedIn === true) return Locale.Browser.LoggedIn.Yes; + if (loggedIn === false) return Locale.Browser.LoggedIn.No; + return Locale.Browser.LoggedIn.Unknown; +} + +function formatBrowserType(status: AdminBrowserStatus | null): string { + if (!status?.browserType) return UNKNOWN; + if (status.headless == null) return status.browserType; + return `${status.browserType} · ${ + status.headless ? Locale.Browser.Headless.Yes : Locale.Browser.Headless.No + }`; +} + +function formatLastError(status: AdminBrowserStatus | null): string { + const error = status?.lastError; + if (!error) return UNKNOWN; + const detail = error.message ? `: ${error.message}` : ""; + return `${error.code}${detail}`; +} + +export function browserStatusRows( + status: AdminBrowserStatus | null, + fetchedAt: number | null, +) { + return [ + { + label: Locale.Browser.Fields.StartedAt, + value: formatDateTime(status?.startedAt), + }, + { + label: Locale.Browser.Fields.Uptime, + value: formatUptime(status?.uptimeMs, status?.startedAt), + }, + { + label: Locale.Browser.Fields.BrowserType, + value: formatBrowserType(status), + }, + { + label: Locale.Browser.Fields.Profile, + value: status?.profileDir ?? UNKNOWN, + }, + { + label: Locale.Browser.Fields.LoggedIn, + value: formatLoggedIn(status?.providerLoggedIn), + }, + { + label: Locale.Browser.Fields.ActiveRequests, + value: + status?.activeRequests == null + ? UNKNOWN + : String(status.activeRequests), + }, + { + label: Locale.Browser.Fields.LastError, + value: formatLastError(status), + }, + { + label: Locale.Browser.Fields.RefreshedAt, + value: formatClock(fetchedAt), + }, + ]; +} + +/** + * 浏览器状态失败提示:HTTP 404 说明后端还没有这个接口,与「后端连不上」区分开。 + * 这是 Admin 面:允许显示后端原始错误码(§36 Admin surface 不受 Public 映射限制)。 + */ +export function browserErrorText( + code: string | null, + httpStatus: number | null, +): string | null { + if (!code) return null; + if (isUnsupportedBrowserApi(code, httpStatus)) { + return Locale.Browser.Unsupported; + } + return `${Locale.Browser.FetchFailed} · ${code}`; +} + +function useBrowserStatusPanel() { + const { + status, + fetchState, + fetchErrorCode, + fetchErrorStatus, + restarting, + fetchedAt, + } = useBrowserStore(); + + useEffect(() => { + void useBrowserStore.getState().refresh(); + return retainBrowserStatusPolling(); + }, []); + + const restart = async () => { + if (restarting) return; + if (!(await showConfirm(Locale.Browser.RestartConfirm))) return; + const result = await useBrowserStore.getState().restart(); + if (result.ok) { + showToast(Locale.Browser.RestartSuccess); + } else if (result.errorText) { + showToast(result.errorText); + } + }; + + const refresh = () => void useBrowserStore.getState().refresh(true); + + return { + status, + fetchState, + restarting, + fetchedAt, + errorText: browserErrorText(fetchErrorCode, fetchErrorStatus), + restart, + refresh, + }; +} + +/** /admin:完整浏览器运维面板(状态 + 明细 + 刷新/重启) */ +export function AdminBrowserPanel() { + const { + status, + fetchState, + restarting, + fetchedAt, + errorText, + restart, + refresh, + } = useBrowserStatusPanel(); + const view = browserStateView(status); + const rows = browserStatusRows(status, fetchedAt); + const subTitle = errorText ?? Locale.Browser.SubTitle; + + return ( + + +
+ + {view.label} +
+
+ + {rows.map((row) => ( + +
+ {row.value} +
+
+ ))} + + +
+ } + bordered + disabled={fetchState === "loading"} + title={Locale.Browser.Actions.Refresh} + onClick={refresh} + /> + +
+
+
+ ); +} diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx index 63dc4d5ff30..a2967be98e3 100644 --- a/app/components/chat-list.tsx +++ b/app/components/chat-list.tsx @@ -15,7 +15,8 @@ import { useLocation, useNavigate } from "react-router-dom"; import { Path } from "../constant"; import { MaskAvatar } from "./mask"; import { Mask } from "../store/mask"; -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; +import type { CSSProperties } from "react"; import { showConfirm } from "./ui-lib"; import { useMobileScreen } from "../utils"; import clsx from "clsx"; @@ -24,7 +25,8 @@ export function ChatItem(props: { onClick?: () => void; onDelete?: () => void; title: string; - count: number; + /** 未加载过消息的会话不展示条数 */ + count?: number; time: string; selected: boolean; id: string; @@ -42,6 +44,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 +62,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 +72,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}
@@ -102,6 +104,13 @@ export function ChatItem(props: { ); } +const paginationStatusStyle: CSSProperties = { + padding: "8px 14px", + fontSize: "12px", + opacity: 0.6, + textAlign: "center", +}; + export function ChatList(props: { narrow?: boolean }) { const [sessions, selectedIndex, selectSession, moveSession] = useChatStore( (state) => [ @@ -115,7 +124,61 @@ export function ChatList(props: { narrow?: boolean }) { const navigate = useNavigate(); const isMobileScreen = useMobileScreen(); + // PAG-1:无限滚动与 DnD 互斥 —— 拖动周期不得改变 Draggable 集合 + const [isDragging, setIsDragging] = useState(false); + // IO 回调要读最新值,拖拽状态用 ref 镜像 + const isDraggingRef = useRef(false); + const sentinelRef = useRef(null); + const { + listNextCursor, + loadingList, + loadingMoreList, + listReloadError, + listMoreError, + } = chatStore; + + const setDragging = (dragging: boolean) => { + isDraggingRef.current = dragging; + setIsDragging(dragging); + }; + + const hasMore = listNextCursor !== null; + + // PAG-1:IntersectionObserver(root = sidebar-body 滚动容器,底部 300px 预载); + // 六条件闸门在回调里读最新 store 状态,cursor=null 由 store 守卫兜底 + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !hasMore) return; + const scrollRoot = sentinel.closest(`.${styles["sidebar-body"]}`) ?? null; + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + const state = useChatStore.getState(); + if ( + state.listNextCursor === null || + state.loadingList || + state.loadingMoreList || + state.listReloadError || + state.listMoreError || + isDraggingRef.current + ) { + return; + } + void state.loadMoreConversations(); + }, + { root: scrollRoot, rootMargin: "0px 0px 300px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasMore]); + + const onDragStart = () => setDragging(true); + const onDragEnd: OnDragEndResponder = (result) => { + // PAG-REVIEW-14:必须先解除拖拽再 early return —— 否则取消拖拽 / 原位 + // 放下后 isDragging 永久 true,Infinite Scroll 从此关闭 + setDragging(false); const { destination, source } = result; if (!destination) { return; @@ -132,7 +195,7 @@ export function ChatList(props: { narrow?: boolean }) { }; return ( - + {(provided) => (
))} {provided.placeholder} + {listReloadError ? ( +
+ {Locale.Home.ReloadError} + +
+ ) : listMoreError ? ( +
+ {Locale.Home.LoadMoreError} + +
+ ) : loadingMoreList ? ( +
+ {Locale.Home.LoadMore} +
+ ) : null} + {hasMore && !listReloadError && ( +
+ )}
)} diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 7560d030533..883d71690db 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -1,10 +1,10 @@ @import "../styles/animation.scss"; .attach-images { - position: absolute; - left: 30px; - bottom: 32px; display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 14px 14px 0; } .attach-image { @@ -13,7 +13,6 @@ height: 64px; border: rgba($color: #888, $alpha: 0.2) 1px solid; border-radius: 5px; - margin-right: 10px; background-size: cover; background-position: center; background-color: var(--white); @@ -42,6 +41,13 @@ } } +/* I3/R25:触摸端无 hover,托盘删除按钮常显 */ +@media only screen and (max-width: 600px) { + .attach-image .attach-image-mask { + opacity: 1; + } +} + .chat-input-actions { display: flex; flex-wrap: wrap; @@ -286,6 +292,22 @@ flex-direction: column; position: relative; height: 100%; + background-color: var(--color-canvas); +} + +.chat-header { + padding: 12px 20px; + border-bottom: 1px solid var(--color-line); + + :global(.window-header-main-title) { + font-size: 16px; + font-weight: 600; + } + + :global(.window-header-sub-title) { + font-size: 12px; + color: var(--color-text-soft); + } } .chat-body { @@ -298,6 +320,63 @@ overscroll-behavior: none; } +/* 空白首页:问候语 + Composer 作为一组垂直居中,不引入欢迎卡片 */ +.chat-empty { + .chat-body-container { + justify-content: center; + } + + .chat-body { + flex: 0 1 auto; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 0; + } + + .chat-input-panel { + padding-top: 20px; + } +} + +.chat-empty-state { + width: 100%; + max-width: var(--chat-column-width); + text-align: center; +} + +.chat-empty-title { + font-size: 20px; + font-weight: 600; + letter-spacing: 0.2px; + color: var(--color-text); +} + +/* PAG-2 §25:顶部历史分页 loading / error+retry / 隐形 sentinel */ +.chat-history-status { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 8px 14px; + font-size: 12px; + opacity: 0.6; + + button { + cursor: pointer; + background: none; + border: none; + padding: 0; + font-size: 12px; + color: var(--primary); + text-decoration: underline; + } +} + +.chat-history-sentinel { + height: 1px; +} + .chat-body-main-title { cursor: pointer; @@ -315,6 +394,10 @@ .chat-message { display: flex; flex-direction: row; + width: 100%; + max-width: var(--chat-column-width); + margin-left: auto; + margin-right: auto; &:last-child { animation: slide-in ease 0.3s; @@ -324,6 +407,10 @@ .chat-message-user { display: flex; flex-direction: row-reverse; + width: 100%; + max-width: var(--chat-column-width); + margin-left: auto; + margin-right: auto; .chat-message-header { flex-direction: row-reverse; @@ -438,13 +525,13 @@ box-sizing: border-box; max-width: 100%; margin-top: 10px; - border-radius: 10px; - background-color: rgba(0, 0, 0, 0.05); - padding: 10px; + border-radius: var(--bubble-radius); + background-color: transparent; + padding: 2px 0 8px; font-size: 14px; user-select: text; word-break: break-word; - border: var(--border-in-light); + border: none; position: relative; transition: all ease 0.3s; } @@ -496,6 +583,22 @@ border: rgba($color: #888, $alpha: 0.2) 1px solid; } +.chat-message-item-image-placeholder { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: var(--border-in-light); + border-radius: 10px; + background-color: var(--gray); + color: var(--black); + font-size: 12px; + margin-top: 10px; + width: fit-content; + max-width: 100%; + cursor: default; +} + @media only screen and (max-width: 600px) { $calc-image-width: calc(100vw / 3 * 2 / var(--image-count)); @@ -544,8 +647,15 @@ z-index: 1; } +.chat-message-error { + font-size: 13px; + color: var(--danger); + margin-top: 6px; +} + .chat-message-user > .chat-message-container > .chat-message-item { - background-color: var(--second); + background-color: var(--color-bubble-user); + padding: 10px 16px; &:hover { min-width: 0; @@ -555,17 +665,26 @@ .chat-input-panel { position: relative; width: 100%; - padding: 20px; - padding-top: 10px; + padding: 8px 20px 18px; box-sizing: border-box; + display: flex; flex-direction: column; - border-top: var(--border-in-light); - box-shadow: var(--card-shadow); + align-items: center; .chat-input-actions { - .chat-input-action { - margin-bottom: 10px; - } + width: 100%; + max-width: var(--chat-column-width); + justify-content: flex-start; + margin-bottom: 6px; + } + + /* Composer 周边的动作都收成轻软胶囊,不再各自带边框卡片 */ + .chat-input-action { + height: 28px; + margin-bottom: 0; + border: none; + box-shadow: none; + background-color: var(--color-surface-soft); } } @@ -578,6 +697,7 @@ .prompt-hints { min-height: 20px; width: 100%; + max-width: var(--chat-column-width); max-height: 50vh; overflow: auto; display: flex; @@ -626,55 +746,96 @@ .chat-input-panel-inner { cursor: text; display: flex; - flex: 1; - border-radius: 10px; - border: var(--border-in-light); -} - -.chat-input-panel-inner-attach { - padding-bottom: 80px; + flex-direction: column; + width: 100%; + max-width: var(--chat-column-width); + box-sizing: border-box; + border-radius: var(--composer-radius); + border: 1px solid var(--color-line); + background-color: var(--color-surface); + box-shadow: var(--shadow-soft); + transition: + border-color ease 0.2s, + box-shadow ease 0.2s; } -.chat-input-panel-inner:has(.chat-input:focus) { - border: 1px solid var(--primary); +.chat-input-panel-inner:focus-within { + border-color: var(--color-line-strong); + box-shadow: var(--shadow-float); } .chat-input { - height: 100%; width: 100%; - border-radius: 10px; border: none; - box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03); - background-color: var(--white); + background-color: transparent; color: var(--black); font-family: inherit; - padding: 10px 90px 10px 14px; + padding: 14px 18px 4px; resize: none; outline: none; box-sizing: border-box; - min-height: 68px; + min-height: 48px; } -.chat-input:focus { +.chat-input-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + cursor: default; + padding: 4px 10px 10px 14px; +} + +/* 工具条里的动作(上传图片)不在 .chat-input-actions 内,拿不到那套 hover 展开样式, + 这里给一枚常驻文字的轻软胶囊,避免图标与文字竖排溢出卡片 */ +.chat-input-panel .chat-input-toolbar .chat-input-action { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 12px; + border: none; + border-radius: 16px; + background-color: var(--color-surface-soft); + box-shadow: none; + font-size: 12px; + color: var(--color-text); + overflow: hidden; + + .icon { + display: flex; + align-items: center; + } + + .text { + white-space: nowrap; + padding-left: 5px; + opacity: 1; + transform: none; + pointer-events: none; + } +} + +.chat-input-toolbar-left, +.chat-input-toolbar-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.chat-input-toolbar-right { + margin-left: auto; } .chat-input-send { background-color: var(--primary); color: white; - - position: absolute; - right: 30px; - bottom: 32px; } @media only screen and (max-width: 600px) { .chat-input { font-size: 16px; } - - .chat-input-send { - bottom: 30px; - } } .shortcut-key-container { @@ -751,3 +912,23 @@ } } } + +/* Phase 5:窄屏收紧留白,Composer 仍占满宽度且不横向滚动 */ +@media only screen and (max-width: 600px) { + .chat-header { + padding: 10px 12px; + } + + .chat-body { + padding: 12px; + padding-bottom: 20px; + } + + .chat-input-panel { + padding: 6px 12px 12px; + } + + .chat-empty-title { + font-size: 18px; + } +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 6691403e65b..2936fbcc1f0 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -4,62 +4,42 @@ import React, { RefObject, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } 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 ImageIcon from "../icons/image.svg"; +import DeleteIcon from "../icons/clear.svg"; import { - BOT_HELLO, ChatMessage, createMessage, - DEFAULT_TOPIC, - ModelType, + getDefaultTopic, + errorTextForCode, SubmitKey, Theme, - useAccessStore, useAppConfig, useChatStore, - usePluginStore, } from "../store"; import { @@ -67,198 +47,72 @@ import { copyToClipboard, getMessageImages, getMessageTextContent, - isDalle3, - isVisionModel, safeLocalStorage, - getModelSizes, - supportsCustomSize, useMobileScreen, - selectOrCopy, - showPlugins, } from "../utils"; -import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; +import { + ATTACHMENT_ACCEPT, + ATTACHMENT_MAX_COUNT, + ATTACHMENT_TOTAL_MAX_FINAL_BYTES, + AttachmentPrepareError, + PendingImage, + prepareAttachment, +} from "../utils/attachment"; import dynamic from "next/dynamic"; -import { ChatControllerPool } from "../client/controller"; -import { DalleQuality, DalleStyle, ModelSize } from "../typing"; import { Prompt, usePromptStore } from "../store/prompt"; +import { useAuthStore } from "../store/auth"; 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, showImageModal, showPrompt, showToast } from "./ui-lib"; +import { ModelSelectorButton } from "./model-selector"; 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={} - /> - ); +/** PAG-2 §23.1:历史分页锚定状态机(废弃 shift-window,唯一 phase 集合) */ +export type PendingHistoryAnchor = { + sessionId: string; + requestedCursor: string; + oldFirstMessageId: string; + messageId: string; + relativeTop: number; + prependedCount: number; + phase: "awaiting-prepend" | "restore-anchor"; }; -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)} /> - )} -
- ); -} +/** + * §23.1 锚定 ref 真正跨越 async loadOlderMessages 存活;模块级持有 + * (ChatInner 经 同时仅一个实例,随重挂载整体销毁) + */ +export const pendingHistoryAnchorRef: { + current: PendingHistoryAnchor | null; +} = { current: null }; + +/** + * PAG2-FIX-01:冷启动 history window 初始化状态机。 + * waiting=mount 时消息链未建,等 loaded;aligning=消息就绪,正把 + * msgRenderIndex+DOM 对齐 latest window;ready=对齐完成,原 PAG-2 逻辑接管。 + * 只描述本组件 incarnation 的首次初始化,ready 即终态,仅 remount 重来。 + */ +type HistoryWindowInitPhase = "waiting" | "aligning" | "ready"; function useSubmitHandler() { const config = useAppConfig(); @@ -379,28 +233,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; @@ -451,7 +283,7 @@ export function ChatAction(props: { } function useScrollToBottom( - scrollRef: RefObject, + scrollRef: RefObject, detach: boolean = false, messages: ChatMessage[], ) { @@ -474,13 +306,23 @@ function useScrollToBottom( } }); - // auto scroll when messages length changes + // auto scroll when messages append(PAG-2 §24:length-effect 收窄为 append-only —— + // 判定:旧数组为空,或新数组首条 id 与旧数组首条 id 相同;prepend 历史不得拉底) const lastMessagesLength = useRef(messages.length); + const lastFirstMessageId = useRef(messages[0]?.id); useEffect(() => { - if (messages.length > lastMessagesLength.current && !detach) { + const appendedOnly = + lastMessagesLength.current === 0 || + messages[0]?.id === lastFirstMessageId.current; + if ( + messages.length > lastMessagesLength.current && + appendedOnly && + !detach + ) { scrollDomToBottom(); } lastMessagesLength.current = messages.length; + lastFirstMessageId.current = messages[0]?.id; }, [messages.length, detach, scrollDomToBottom]); return { @@ -492,23 +334,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 +352,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 +393,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 +418,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 (
@@ -986,7 +459,66 @@ export function ShortcutKeyModal(props: { onClose: () => void }) { ); } -function _Chat() { +/** + * I3/H1:附件 owner 身份(区分 PROMOTION / SWITCH,禁止只靠 [session.id])。 + * 状态全部放外层 Chat(keyed ChatInner 之外),经 props 传给 ChatInner。 + */ +type AttachmentOwner = { + sessionId: string; + currentSessionIndex: number; + wasDraft: boolean; +}; + +/** I3:outer Chat → ChatInner 的附件 composer props 组(H1;gate/epoch 全在 outer 实现) */ +export type AttachmentController = { + pendingImages: PendingImage[]; + isPreparingImages: boolean; + isSubmittingMessage: boolean; + addFiles: (files: FileList | File[]) => void; + removeAt: (id: string) => void; + submit: (text: string) => void; +}; + +/** I3-B:I3-A 支持集的派生视图(唯一真值仍是 utils/attachment.ts ATTACHMENT_ACCEPT) */ +const ACCEPTED_ATTACHMENT_MIME_TYPES = new Set(ATTACHMENT_ACCEPT.split(",")); + +/** I3-B:clipboard File 允许空名,空名补名表(仅 I3-A 支持 MIME;F9 名实相符) */ +const PASTE_IMAGE_NAME_BY_MIME = new Map([ + ["image/png", "paste-image.png"], + ["image/jpeg", "paste-image.jpg"], + ["image/webp", "paste-image.webp"], + ["image/gif", "paste-image.gif"], +]); + +/** I3-B:剪贴板 → File[](只做事件源适配;最终 MIME 校验在 prepareAttachment) */ +function extractClipboardImages(data: DataTransfer | null): File[] { + if (!data) return []; + const files: File[] = []; + for (let index = 0; index < data.items.length; index += 1) { + const item = data.items[index]; + if (item.kind !== "file" || !item.type.toLowerCase().startsWith("image/")) { + continue; + } + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; +} + +function normalizeClipboardImageNames(files: File[]): File[] { + return files.map((file) => { + if (file.name !== "") return file; + const fallbackName = PASTE_IMAGE_NAME_BY_MIME.get(file.type.toLowerCase()); + if (fallbackName === undefined) return file; + return new File([file], fallbackName, { + type: file.type, + lastModified: file.lastModified, + }); + }); +} + +function ChatInner(props: { attachment: AttachmentController }) { + const attachment = props.attachment; type RenderMessage = ChatMessage & { preview?: boolean }; const chatStore = useChatStore(); @@ -998,8 +530,8 @@ function _Chat() { const [showExport, setShowExport] = useState(false); const inputRef = useRef(null); + const fileInputRef = 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 +563,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 +603,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), }); @@ -1102,20 +626,49 @@ function _Chat() { } }; + // I3-B:三阶段 gate —— picker(uploadDisabled)与 paste 共用同一判据 + const attachmentDisabled = + attachment.isPreparingImages || + attachment.isSubmittingMessage || + session.pendingRequestId !== undefined; + + // I3-B:粘贴图片 = 纯适配层;校验/压缩/限额/epoch 全在 I3-A addFiles 链路。 + // gate 命中时不收图也不 preventDefault(文字仍走浏览器默认粘贴); + // 仅 supported image 获得「图片优先」(unsupported 交给 prepareAttachment 拒绝, + // 不得连带吞掉剪贴板文本)。 + const onPasteImages = (event: React.ClipboardEvent) => { + if (attachmentDisabled) return; + const files = normalizeClipboardImageNames( + extractClipboardImages(event.clipboardData), + ); + if (files.length === 0) return; + const hasSupportedImage = files.some((file) => + ACCEPTED_ATTACHMENT_MIME_TYPES.has(file.type.toLowerCase()), + ); + if (hasSupportedImage) { + event.preventDefault(); + } + attachment.addFiles(files); + }; + const doSubmit = (userInput: string) => { - if (userInput.trim() === "" && isEmpty(attachImages)) return; - const matchCommand = chatCommands.match(userInput); - if (matchCommand.matched) { - setUserInput(""); - setPromptHints([]); - matchCommand.invoke(); + // I3/R23:准备中 / POST 在途守卫(真拦截在 outer submit 的 submittingRef); + // 联合判据:文本 ∨ 附件至少一个(纯图 content="") + if (attachment.isPreparingImages) return; + if (attachment.isSubmittingMessage) return; + if (userInput.trim() === "" && attachment.pendingImages.length === 0) return; + // §39:command 仅在文本非空时尝试(纯图不得进入 command parser,现序不变) + if (userInput.trim() !== "") { + const matchCommand = chatCommands.match(userInput); + if (matchCommand.matched) { + setUserInput(""); + setPromptHints([]); + matchCommand.invoke(); + return; + } } - setIsLoading(true); - chatStore - .onUserInput(userInput, attachImages) - .then(() => setIsLoading(false)); - setAttachImages([]); + attachment.submit(userInput); chatStore.setLastInput(userInput); setUserInput(""); setPromptHints([]); @@ -1140,40 +693,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,216 +710,228 @@ 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)), + // 历史消息完全来自后端,不再注入本地 context 与 BOT_HELLO 占位: + // 空会话就是空的,首条回答由后端建 Conversation 后才出现。 + const renderMessages = useMemo(() => { + return (session.messages as RenderMessage[]).concat( + userInput.length > 0 && config.sendPreviewBubble + ? [ + { + ...createMessage({ + role: "user", + content: userInput, + }), + preview: true, + }, + ] + : [], ); - }; + }, [config.sendPreviewBubble, session.messages, userInput]); - const onDelete = (msgId: string) => { - deleteMessage(msgId); - }; + const [msgRenderIndex, _setMsgRenderIndex] = useState( + Math.max(0, renderMessages.length - CHAT_PAGE_SIZE), + ); + + function setMsgRenderIndex(newIndex: number) { + newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex); + newIndex = Math.max(0, newIndex); + _setMsgRenderIndex(newIndex); + } - 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 + // PAG2-FIX-01:已 loaded 的会话 mount(如 A→B→A)初始即 aligning; + // 冷启动(loaded=false)从 waiting 开始,等 store 建链 + const [historyWindowInitPhase, setHistoryWindowInitPhase] = + useState(session.loaded ? "aligning" : "waiting"); - const resendingIndex = session.messages.findIndex( - (m) => m.id === message.id, + const messages = useMemo(() => { + const endRenderIndex = Math.min( + msgRenderIndex + 3 * CHAT_PAGE_SIZE, + renderMessages.length, ); + return renderMessages.slice(msgRenderIndex, endRenderIndex); + }, [msgRenderIndex, renderMessages]); - if (resendingIndex < 0 || resendingIndex >= session.messages.length) { - console.error("[Chat] failed to find resending message", message); + // PAG-2 §21:IO 回调读取镜像,七条件之四(msgRenderIndex===0)在触发时复核 + const msgRenderIndexRef = useRef(msgRenderIndex); + msgRenderIndexRef.current = msgRenderIndex; + + // PAG-2 §25:顶部三态 + 隐形 sentinel 的可见性(cursor!==null 是全部三态前提) + // PAG2-FIX-01:初始化未 ready 前不挂 sentinel/IO —— msgRenderIndex 尚未 + // 对齐 latest window 时,即使七条件表面成立也不允许 history 分页启动 + const hasHistoryMore = + !session.draft && session.loaded && session.messageNextCursor !== null; + const historySentinelVisible = + historyWindowInitPhase === "ready" && + hasHistoryMore && + msgRenderIndex === 0 && + !session.loadingOlderMessages && + !session.messageHistoryError; + + // PAG-2 §23.2:IO / Retry 唯一写入口 —— 请求前写入 anchor,跨 async 存活 + async function requestOlderMessages(fromRetry = false) { + const container = scrollRef.current; + if (!container) return; + const target = useChatStore + .getState() + .sessions.find((s) => s.id === session.id); + if ( + !target || + target.draft || + !target.loaded || + target.messageNextCursor === null || + target.loadingOlderMessages + ) { 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 (!fromRetry && target.messageHistoryError) return; + if (msgRenderIndexRef.current !== 0) return; + const firstPersisted = target.messages.find( + (m) => m.position !== undefined, + ); + if (!firstPersisted) return; + const anchorEl = container.querySelector( + `[data-message-id="${firstPersisted.id}"]`, + ); + if (!anchorEl) return; + const anchor: PendingHistoryAnchor = { + sessionId: target.id, + requestedCursor: target.messageNextCursor, + oldFirstMessageId: firstPersisted.id, + messageId: firstPersisted.id, + relativeTop: + anchorEl.getBoundingClientRect().top - + container.getBoundingClientRect().top, + prependedCount: 0, + phase: "awaiting-prepend", + }; + pendingHistoryAnchorRef.current = anchor; + const result = await useChatStore.getState().loadOlderMessages(target.id); + if (!result.applied || result.prependedCount === 0) { + // §23.4 一/二/六:失败/stale/guard no-op/空页;身份校验,不误清新一轮 + if (pendingHistoryAnchorRef.current === anchor) { + pendingHistoryAnchorRef.current = null; } } + } - if (userMessage === undefined) { - console.error("[Chat] failed to resend", message); + // PAG-2 §21:IntersectionObserver(root=.chat-body,顶部 300px 预载); + // sentinel 仅在七条件成立时渲染,msgRenderIndex>0 时窗口滑动零请求 + const sentinelRef = useRef(null); + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + void requestOlderMessages(); + }, + { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [historySentinelVisible]); + + // PAG-2 §23.3:two-phase useLayoutEffect(每次 commit 都跑,由 phase 状态机驱动) + useLayoutEffect(() => { + const pending = pendingHistoryAnchorRef.current; + if (!pending || pending.sessionId !== session.id) return; + if (pending.phase === "awaiting-prepend") { + // Phase 1 唯一依据 = messages 本身(REVIEW-25),精确计算 prepend 数 + const prependCount = session.messages + .filter((m) => m.position !== undefined) + .findIndex((m) => m.id === pending.oldFirstMessageId); + if (prependCount > 0) { + pending.prependedCount = prependCount; + pending.phase = "restore-anchor"; + setMsgRenderIndex(msgRenderIndex + prependCount); // 主补偿 §22 + return; // 本 commit 不做任何 DOM 修正 + } + if (prependCount === -1) { + pendingHistoryAnchorRef.current = null; // §23.4 五:原链被 latest gap/reset 替换 + } + // ===0:send append / SSE patch / loadOlder 仍在飞,Phase 1 不区分、无 UI 副作用 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), + // phase === "restore-anchor":第二次 commit,paint 前同步修正 + const container = scrollRef.current; + if (!container) return; + const el = container.querySelector( + `[data-message-id="${pending.messageId}"]`, ); - - 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); + if (el) { + const newRelativeTop = + el.getBoundingClientRect().top - container.getBoundingClientRect().top; + container.scrollTop += newRelativeTop - pending.relativeTop; } 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; + // §23.4:anchor 不在窗口 → 降级,记日志,不做猜测性 scrollTop 修正 + console.debug("[Chat] history anchor not in window", pending.messageId); } - context.push(copiedHello); - } + pendingHistoryAnchorRef.current = null; + }); - // preview messages - 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, - ]); + // PAG2-FIX-01 Phase1:loaded 翻转后先以正确 msgRenderIndex 重新 commit 一次 + // DOM;此处不做 history IO、不依赖 IntersectionObserver、不执行 loadOlder + useLayoutEffect(() => { + if (historyWindowInitPhase !== "waiting" || !session.loaded) return; + setMsgRenderIndex(Math.max(0, renderMessages.length - CHAT_PAGE_SIZE)); + setHistoryWindowInitPhase("aligning"); + }); - const [msgRenderIndex, _setMsgRenderIndex] = useState( - Math.max(0, renderMessages.length - CHAT_PAGE_SIZE), - ); + // PAG2-FIX-01 Phase2:latest window 已 commit,对 .chat-body 做一次初始 + // 贴底并打开 gate。ready 后不再重入,refresh/SSE/append/loadOlder 均无副作用 + useLayoutEffect(() => { + if (historyWindowInitPhase !== "aligning") return; + const latestWindowIndex = Math.max( + 0, + renderMessages.length - CHAT_PAGE_SIZE, + ); + if (msgRenderIndex !== latestWindowIndex) { + // 对齐期间窗口又漂移(如 preview 气泡)→ 以当前 length 再对齐一次 + setMsgRenderIndex(latestWindowIndex); + return; + } + const dom = scrollRef.current; + if (dom) { + dom.scrollTo(0, dom.scrollHeight); + } + setAutoScroll(true); + setHitBottom(true); + setHistoryWindowInitPhase("ready"); + }); - function setMsgRenderIndex(newIndex: number) { - newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex); - newIndex = Math.max(0, newIndex); - _setMsgRenderIndex(newIndex); + // PAG-2 §27.2 Header displayedCount 公式(REVIEW-14/17): + // draft / 未建链 → 本地可见条数(禁 0+2 冒充);建链 → totalCount + 本地 transient + const localTransientCount = session.messages.filter( + (m) => m.position === undefined, + ).length; + const historyTotalCount = + session.draft || !session.loaded + ? session.messages.length + : session.messageTotalCount + localTransientCount; + + // PAG-2 §27.1 Export 独立 snapshot 接线:组件态持有 prepared snapshot, + // 不写回 ChatStore;preparingExport 期间按钮 disabled 防重复点击 + const [preparingExport, setPreparingExport] = useState(false); + const [preparedExportMessages, setPreparedExportMessages] = useState< + ChatMessage[] + >([]); + + async function onExport() { + if (preparingExport) return; + setPreparingExport(true); + try { + const snapshot = await chatStore.prepareMessagesForExport(session.id); + setPreparedExportMessages(snapshot); + setShowExport(true); + } catch (error) { + console.error("[Export] ", error); + showToast(Locale.Chat.ExportFailed); + } finally { + setPreparingExport(false); + } } - const messages = useMemo(() => { - const endRenderIndex = Math.min( - msgRenderIndex + 3 * CHAT_PAGE_SIZE, - renderMessages.length, - ); - return renderMessages.slice(msgRenderIndex, endRenderIndex); - }, [msgRenderIndex, renderMessages]); - const onChatBodyScroll = (e: HTMLElement) => { const bottomHeight = e.scrollTop + e.clientHeight; const edgeThreshold = e.clientHeight; @@ -1428,13 +959,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 +986,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,93 +1005,21 @@ 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); - } + /** + * V1.4 U4 §51/§52:换主体时必须连本地草稿一起清掉。 + * + * resetForIdentity() 清的是 store 的 lastInput,而这个输入框真正的值是组件本地的 + * userInput —— 401 掉身份、旁观 Tab 被顶号这类「同一个文档里换身份」不会卸载组件, + * 于是上一身份没发出去的文本会留在框里。只盯 identityEpoch:它仅在真正换主体时自增, + * 所以注册的原地升级(同一 userId)不会误清(§52 / INPUT-ID-04)。 + */ + const identityEpoch = useAuthStore((state) => state.identityEpoch); + const seenIdentityEpoch = useRef(identityEpoch); + useEffect(() => { + if (seenIdentityEpoch.current === identityEpoch) return; + seenIdentityEpoch.current = identityEpoch; + setUserInput(""); + }, [identityEpoch]); // 快捷键 shortcut keys const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false); @@ -1652,22 +1076,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,14 +1083,21 @@ function _Chat() { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [messages, chatStore, navigate, session]); + }, [messages, chatStore, navigate]); - const [showChatSidePanel, setShowChatSidePanel] = useState(false); + // §4:空白首页 —— 会话确实没有消息时才给问候语(未加载完的历史不算) + const isEmptyChat = messages.length === 0 && session.loaded !== false; return ( <> -
-
+
+
{isMobileScreen && (
@@ -1704,12 +1119,12 @@ function _Chat() { "window-header-main-title", styles["chat-body-main-title"], )} - onClickCapture={() => setIsEditingMessage(true)} + onClickCapture={renameCurrentSession} > - {!session.topic ? DEFAULT_TOPIC : session.topic} + {!session.topic ? getDefaultTopic() : session.topic}
- {Locale.Chat.SubTitle(session.messages.length)} + {Locale.Chat.SubTitle(historyTotalCount)}
@@ -1717,11 +1132,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 +1141,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} />
)} @@ -1740,9 +1172,8 @@ function _Chat() { icon={} bordered title={Locale.Chat.Actions.Export} - onClick={() => { - setShowExport(true); - }} + disabled={preparingExport} + onClick={() => void onExport()} />
{showMaxIcon && ( @@ -1761,12 +1192,6 @@ function _Chat() {
)}
- -
@@ -1780,264 +1205,189 @@ 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], - }, - }); - } + {isEmptyChat && ( +
+
+ {Locale.Store.BotHello} +
+
+ )} + {hasHistoryMore && + (session.loadingOlderMessages ? ( +
+ {Locale.Chat.HistoryLoading} +
+ ) : session.messageHistoryError ? ( +
+ {Locale.Chat.HistoryError} + +
+ ) : historySentinelVisible ? ( +
+ ) : null)} + {messages.map((message, i) => { + const isUser = message.role === "user"; + const messageImages = getMessageImages(message); + // I3.5:历史图片份数(旧后端缺省 0);真实 image_url 存在时优先渲染真实图片 + const attachmentCount = message.attachmentCount ?? 0; + 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 && ( +
+
+ onRightClick(e, message)} // hard to use + onDoubleClickCapture={() => { + if (!isMobileScreen) return; + setUserInput(getMessageTextContent(message)); + }} + fontSize={fontSize} + fontFamily={fontFamily} + parentRef={scrollRef} + defaultShow={i >= messages.length - 6} + /> + {messageImages.length > 0 ? ( + messageImages.length === 1 ? ( showImageModal(messageImages[0])} /> - )} - {getMessageImages(message).length > 1 && ( + ) : (
- {getMessageImages(message).map( - (image, index) => { - return ( - - ); - }, - )} + {messageImages.map((image, imageIndex) => ( + showImageModal(image)} + /> + ))}
- )} -
- {message?.audio_url && ( -
-
-
- {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,95 +1410,127 @@ function _Chat() { onSearch(""); }} setShowShortcutKeyModal={setShowShortcutKeyModal} - setUserInput={setUserInput} - setShowChatSidePanel={setShowChatSidePanel} + /> + { + if (e.currentTarget.files?.length) { + attachment.addFiles(e.currentTarget.files); + } + e.currentTarget.value = ""; + }} />