diff --git a/docs/openvibecoding-内网沙箱改造-开发方案.md b/docs/openvibecoding-内网沙箱改造-开发方案.md new file mode 100644 index 00000000..0d5c2627 --- /dev/null +++ b/docs/openvibecoding-内网沙箱改造-开发方案.md @@ -0,0 +1,304 @@ +# OpenVibeCoding 内网可视化应用生成平台:分步开发方案 + +> 文档状态:待实施 +> +> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。 +> +> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。 + +## 1. 目标与范围 + +将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。 + +第一期只服务以下项目类型: + +- 数据大屏、数据看板、海报和展示型页面; +- React + Vite + TypeScript 前端; +- ECharts 图表、流程图; +- 可选的轻量 Python FastAPI 接口; +- 项目文件浏览、编辑、运行日志和实时预览。 + +第一期明确不做: + +- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署; +- 任意语言、任意 npm/PyPI 包的无限制安装; +- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行; +- 直接将沙箱暴露为独立公网端口; +- 对不可信代码提供“绝对安全”的隔离承诺。 + +## 2. 已确认的服务器基线 + +目标服务器当前具备: + +| 项目 | 巡检结果 | 判断 | +|---|---:|---| +| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 | +| CPU | 32 vCPU | 足够第一期 | +| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 | +| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 | +| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 | +| 负载 | 约 0.5 | 当前有余量 | +| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 | + +该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。 + +该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。 + +## 3. 总体架构 + +```mermaid +flowchart LR + U[用户浏览器] -->|:8020| G[App Builder Gateway] + U -->|:8022 /preview//| P[Preview Router] + + G --> O[OpenVibe Web / Agent Server] + O --> M[现有内网模型网关] + O --> DB[(独立 PostgreSQL)] + O --> R[(独立 Redis)] + O --> SM[Local Sandbox Manager] + + SM --> D[Docker Engine] + D --> S1[visual-ui Sandbox] + D --> S2[visual-fullstack Sandbox] + P --> S1 + P --> S2 + + D --> H[Harbor :8023] + S1 --> N[内网 pnpm/npm 源或预置离线依赖] + S2 --> N +``` + +设计原则: + +1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。 +2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。 +3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。 +4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。 +5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。 + +## 4. 端口规划 + +端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。 + +| 端口 | 服务 | 绑定范围 | 用途 | +|---:|---|---|---| +| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket | +| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 | +| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview//` | +| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry | +| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 | +| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 | + +### 4.1 预览路由规则 + +暂时没有可用泛域名时,使用路径型预览: + +```text +http(s)://<服务器地址>:8022/preview// +``` + +项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。 + +## 5. 镜像、模板与离线依赖策略 + +### 5.1 标准镜像 + +第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile: + +| 镜像 | 用途 | 预置内容 | +|---|---|---| +| `appbuilder/visual-ui:` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 | +| `appbuilder/visual-fullstack:` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic | + +流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。 + +### 5.2 项目模板 + +每次创建项目时从镜像中的模板初始化到 `/workspace`: + +```text +templates/ + visual-screen/ # React + ECharts + React Flow + Mock 数据 + visual-screen-api/ # visual-screen + FastAPI + /api 反向代理 + poster-page/ # 海报、营销展示、静态动效 +``` + +`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。 + +### 5.3 依赖预置方式 + +不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是: + +1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。 +2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。 +3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。 +4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。 +5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。 + +## 6. OpenVibe 改造边界 + +### 6.1 保留 + +- 前端交互:对话、任务状态、文件浏览与修改、预览、日志; +- OpenCode ACP 代码代理; +- 项目与会话领域模型; +- 代码代理的流式事件协议。 + +### 6.2 替换或关闭 + +| OpenVibe 当前能力 | 第一期开源/内网实现 | +|---|---| +| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 | +| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` | +| TCR | Harbor | +| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 | +| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 | +| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 | +| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 | + +重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。 + +## 7. 沙箱安全基线 + +每个沙箱容器必须满足: + +- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载; +- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`; +- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收; +- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调; +- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围; +- 运行命令、容器生命周期、文件变更和预览访问写入审计日志; +- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。 + +第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。 + +## 8. 分步实施计划 + +### 阶段 0:部署前隔离与基线确认 + +**工作项** + +1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。 +2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。 +3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。 +4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。 +5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。 + +**验收** + +- `8020-8030` 无端口冲突; +- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务; +- 业务数据库和沙箱端口无法从非授权公网地址访问。 + +### 阶段 1:内网基础设施与镜像供应链 + +**工作项** + +1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。 +2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。 +3. 完成三个模板及离线依赖初始化脚本。 +4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。 +5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。 + +**验收** + +- Docker daemon 能从 Harbor 拉取指定版本镜像; +- 在禁止公网访问的测试网络中,三个模板均能启动并构建; +- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。 + +### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入 + +**工作项** + +1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。 +2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。 +3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。 +4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。 +5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。 + +**验收** + +- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动; +- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件; +- 服务端日志和沙箱环境中均没有模型 API Key。 + +### 阶段 3:本地沙箱管理器与预览路由 + +**工作项** + +1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。 +2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。 +3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。 +4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview//` 代理到对应内部容器;支持 WebSocket/HMR。 +5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。 + +**验收** + +- 两个用户创建的项目存在于不同容器和不同工作区; +- 修改 ECharts 配置文件后,预览地址显示修改结果; +- FastAPI 模板的 `/api/health` 可通过前端相对路径访问; +- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络; +- 空闲超时后容器被回收,项目文件仍可恢复。 + +### 阶段 4:产品能力收口 + +**工作项** + +1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。 +2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。 +3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。 +4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。 +5. 增加项目快照、模板版本记录和基础审计日志。 + +**验收** + +- 从一句自然语言需求到可预览项目的完整链路可完成; +- 用户可查看并手动修改文件,刷新后改动保留; +- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口; +- 非拥有者不能读取、修改或预览另一用户的项目。 + +### 阶段 5:上线前安全与运行验收 + +**工作项** + +1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。 +2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。 +3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。 +4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。 + +**验收** + +- 并发运行时既有 DeerFlow 等服务不受明显影响; +- 任一沙箱异常退出不会影响其他项目或平台控制服务; +- 备份可恢复项目元数据、工作区和 Harbor 镜像; +- 仅开放计划中的入口端口,内部组件无不必要公网监听。 + +## 9. 首期资源与并发建议 + +由于该主机已有多项业务,首期不应按理论最大值分配: + +| 组件 | 首期限制 | +|---|---| +| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 | +| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 | +| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 | +| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 | +| 沙箱并发 | 先限制为 6;稳定后再逐步上调 | + +当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。 + +## 10. 实施顺序与交付物 + +建议按以下提交/交付顺序推进,每一步都可独立验收和回退: + +1. `docs: confirm appbuilder network, ports and security baseline` +2. `infra: add Harbor and versioned visual sandbox images` +3. `infra: add offline templates and dependency locks` +4. `feat: replace OpenVibe CloudBase persistence and model provider` +5. `feat: add local Docker sandbox manager` +6. `feat: add sandbox runner and path-based preview router` +7. `feat: add visual-screen and FastAPI project workflows` +8. `test: add sandbox isolation, preview and recovery coverage` +9. `ops: add backup, cleanup, monitoring and rollout runbook` + +达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。 diff --git a/frontend-web/src/components/ai-elements/prompt-input.tsx b/frontend-web/src/components/ai-elements/prompt-input.tsx index a796b775..58dc06ea 100644 --- a/frontend-web/src/components/ai-elements/prompt-input.tsx +++ b/frontend-web/src/components/ai-elements/prompt-input.tsx @@ -56,12 +56,11 @@ import { type ChangeEventHandler, Children, type ClipboardEventHandler, - type ComponentProps, - createContext, - type FormEvent, - type FormEventHandler, - Fragment, - type HTMLAttributes, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + type HTMLAttributes, type KeyboardEventHandler, type PropsWithChildren, type ReactNode, @@ -124,9 +123,11 @@ export const usePromptInputController = () => { return ctx; }; -// Optional variants (do NOT throw). Useful for dual-mode components. -const useOptionalPromptInputController = () => - useContext(PromptInputController); +// Optional variants (do NOT throw). Useful for dual-mode components and for +// Fast Refresh transitions where a page can briefly retain the previous +// provider context instance. +export const useOptionalPromptInputController = () => + useContext(PromptInputController); export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); @@ -403,14 +404,14 @@ export function PromptInputAttachments({ return (
- {attachments.files.map((file) => ( - -
{children(file)}
-
- ))} -
+ {...props} + > + {attachments.files.map((file) => ( +
+ {children(file)} +
+ ))} + ); } diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 4589c288..19fad244 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -22,6 +22,7 @@ import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-f import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; +import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { useSkills } from "@/core/skills/hooks"; @@ -87,7 +88,11 @@ const ChatBox: React.FC<{ threadId, disableArtifacts = false, }) => { - const { isMock, thread } = useThread(); + const { + isMock, + thread, + knowledgeReferencesEnabled = false, + } = useThread(); const pathname = usePathname(); const threadIdRef = useRef(threadId); const artifactsCountRef = useRef(0); @@ -106,8 +111,13 @@ const ChatBox: React.FC<{ const writingModeEnabled = !!settings.context.writing_mode; const artifactDirectoryModeEnabled = settings.display.artifactDirectoryModeEnabled ?? false; + const { data: citationDisplay } = useCitationDisplaySettings(); + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; const ragModeEnabled = - settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + (referenceModeEntryEnabled && settings.display.ragModeEnabled) || + isThreadRagModeLocked(threadId) || + knowledgeReferencesEnabled; // Frontend RAG modes write their own hidden source tool messages. They use // the existing reference panel, but must not request or write reference // batches through DeerFlow's backend APIs. @@ -121,7 +131,7 @@ const ChatBox: React.FC<{ ); const referencesEnabled = ragModeEnabled || hasFrontendRag; const shouldFetchBackendReferenceBatches = - referencesEnabled && !hasFrontendRag; + referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new"; const { skills } = useSkills(); const canScanArtifactDirectory = diff --git a/frontend-web/src/components/workspace/chats/use-chat-mode.ts b/frontend-web/src/components/workspace/chats/use-chat-mode.ts index 232fccd6..2f73fac4 100644 --- a/frontend-web/src/components/workspace/chats/use-chat-mode.ts +++ b/frontend-web/src/components/workspace/chats/use-chat-mode.ts @@ -1,7 +1,7 @@ import { useParams, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef } from "react"; -import { usePromptInputController } from "@/components/ai-elements/prompt-input"; +import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input"; import { useI18n } from "@/core/i18n/hooks"; /** @@ -11,7 +11,11 @@ export function useSpecificChatMode() { const { t } = useI18n(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const searchParams = useSearchParams(); - const promptInputController = usePromptInputController(); + // A regular history item can navigate here while Vite Fast Refresh is + // reconciling the previous page tree. In that short window the page may see + // a stale provider context. This hook only pre-fills the skill-mode draft, + // so it must not make an ordinary chat page fail to render. + const promptInputController = useOptionalPromptInputController(); const inputInitialValue = useMemo(() => { if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") { return undefined; @@ -19,16 +23,21 @@ export function useSpecificChatMode() { return t.inputBox.createSkillPrompt; }, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]); const lastInitialValueRef = useRef(undefined); - const setInputRef = useRef(promptInputController.textInput.setInput); - setInputRef.current = promptInputController.textInput.setInput; + const setInputRef = useRef<((value: string) => void) | undefined>( + promptInputController?.textInput.setInput, + ); + setInputRef.current = promptInputController?.textInput.setInput; useEffect(() => { if ( inputInitialValue && inputInitialValue !== lastInitialValueRef.current ) { lastInitialValueRef.current = inputInitialValue; - setTimeout(() => { - setInputRef.current(inputInitialValue); + setTimeout(() => { + const setInput = setInputRef.current; + if (!setInput) return; + + setInput(inputInitialValue); const textarea = document.querySelector("textarea"); if (textarea) { textarea.focus(); diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index 46d26231..43e6062d 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -7,7 +7,8 @@ * 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难 * 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。 * - * 三块结构(自上而下): + * 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider + * 决定。三块结构(自上而下): * 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮; * 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条); * 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单: @@ -85,6 +86,7 @@ import { titleOfThread, } from "@/core/threads/utils"; import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route"; +import { useRouteBase } from "@/core/route-base"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { isIMEComposing } from "@/lib/ime"; @@ -93,11 +95,6 @@ import { cn } from "@/lib/utils"; import { agentColor } from "./agent-icon-color"; import { SidebarCollapseButton } from "./workspace-header"; -const NEW_CHAT_PATH = "/page/workspace/chats/new"; -/** 「更多」常用智能体跳转目标:智能体管理页。 */ -const MORE_AGENTS_HREF = "/page/workspace/agents"; -/** 「更多最近对话」跳转目标。 */ -const MORE_CHATS_HREF = "/page/workspace/chats"; /** 常用智能体最多展示个数。 */ const MAX_FREQUENT = 4; @@ -142,6 +139,7 @@ function SectionLabel({ export function GeneralQaSidebar() { const { t } = useI18n(); + const routeBase = useRouteBase(); // 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。 const { theme } = useTheme(); const isDark = theme === "dark"; @@ -188,9 +186,14 @@ export function GeneralQaSidebar() { const searchParams = useSearchParams(); const activeAgentId = searchParams.get("agent"); const currentSearch = searchParams.toString(); - const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch); - const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch); - const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch); + const chatsBasePath = `${routeBase}/chats`; + const newChatPath = `${chatsBasePath}/new`; + const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch); + const moreAgentsHref = buildFullChatNavigationPath( + `${routeBase}/agents`, + currentSearch, + ); + const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch); // 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new, // 仅给 URL 加 ?agent=,由 NewChatContent 把右侧问答区换成 AgentChatPage。 @@ -199,19 +202,18 @@ export function GeneralQaSidebar() { (agentId: string) => { navigate( buildFullChatNavigationPath( - `${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`, + routeBase === "/page/workspace" + ? `${newChatPath}?agent=${encodeURIComponent(agentId)}` + : `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`, currentSearch, ), ); }, - [currentSearch, navigate], + [currentSearch, navigate, newChatPath, routeBase], ); // ── 最近的对话取数 + 搜索 ── - const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{ - thread_id: string; - agent_id?: string; - }>(); + const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { data: threads = [] } = useThreads(); const { mutate: deleteThread } = useDeleteThread(); const { mutate: renameThread } = useRenameThread(); @@ -224,6 +226,14 @@ export function GeneralQaSidebar() { const [renameValue, setRenameValue] = useState(""); const [shareThreadId, setShareThreadId] = useState(null); + const pathForThread = useCallback( + (thread: AgentThread) => + isBootstrapThread(thread) + ? listPathOfThread(thread) + : pathOfThread(thread, undefined, routeBase), + [routeBase], + ); + const visibleThreads = useMemo( () => // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); @@ -254,21 +264,22 @@ export function GeneralQaSidebar() { deleteThread({ threadId }); if (threadId === threadIdFromPath) { const threadIndex = threads.findIndex((th) => th.thread_id === threadId); - let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); + let nextThreadPath = newChatPath; if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); + nextThreadPath = pathForThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); + nextThreadPath = pathForThread(threads[threadIndex - 1]!); } } void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch)); } }, [ - agentIdFromPath, currentSearch, deleteThread, + newChatPath, + pathForThread, router, threadIdFromPath, threads, @@ -467,7 +478,7 @@ export function GeneralQaSidebar() { ) : ( filteredThreads.map((thread) => { const href = buildFullChatNavigationPath( - listPathOfThread(thread), + pathForThread(thread), currentSearch, ); const isActive = href === pathname; diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 254a18ca..3deab8dc 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -93,14 +93,14 @@ import { import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useUserPreferences } from "@/core/user-preferences"; -import { - isThreadRagModeLocked, - lockThreadRagMode, - useLocalSettings, -} from "@/core/settings"; - - -import { CustomPromptBar } from "./custom-prompt-menu"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "@/core/settings"; +import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; + +import { CustomPromptBar } from "./custom-prompt-menu"; import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; @@ -143,6 +143,7 @@ export function InputBox({ hideAdminRecommendedQuestions, compact, hideContextTools, + hideMemoryTool = true, ...props }: Omit, "onSubmit"> & { assistantId?: string | null; @@ -153,6 +154,11 @@ export function InputBox({ /** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等 * 受控场景用;文件/模型等其他按钮不受影响)。 */ hideContextTools?: boolean; + /** + * 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。 + * 写作和参考文献开关不受影响。 + */ + hideMemoryTool?: boolean; disabled?: boolean; context: Omit< AgentThreadContext, @@ -221,9 +227,12 @@ export function InputBox({ const modelOptions = useMemo(() => { return singleRoundRagActive ? extraModels : models; }, [extraModels, models, singleRoundRagActive]); - const { thread, isMock } = useThread(); - const { textInput } = usePromptInputController(); - const promptRootRef = useRef(null); + const { thread, isMock } = useThread(); + const { textInput } = usePromptInputController(); + const promptRootRef = useRef(null); + const { data: citationDisplay } = useCitationDisplaySettings(); + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; const [followups, setFollowups] = useState([]); const [followupsHidden, setFollowupsHidden] = useState(false); @@ -749,7 +758,7 @@ export function InputBox({ )} /> )} - {!frontendRagActive && !hideContextTools && 文件 } - {!frontendRagActive && !hideContextTools && 记忆 } - {!frontendRagActive && !hideContextTools && ; - isMock?: boolean; - /** - * When set, the conversation is rendered from a public read-only share. +export interface ThreadContextType { + thread: BaseStream; + isMock?: boolean; + /** + * True when the active conversation has selected LLMWiki knowledge bases. + * Knowledge-base Q&A should always expose its source documents, independent + * from the admin-controlled manual reference-mode button. + */ + knowledgeReferencesEnabled?: boolean; + /** + * When set, the conversation is rendered from a public read-only share. * Artifact/image URLs then resolve to the unauthenticated * `/api/public/shares/{code}/files/...` endpoint instead of the * auth-gated thread artifact route. diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index ddd7bd20..33c03220 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -83,16 +83,18 @@ import { SourceCard } from "../skill-display-sources"; import { MarkdownContent } from "./markdown-content"; import { SingleRoundRagCitationDialog } from "./message-list-item"; -export function MessageGroup({ - className, - messages, - isLoading = false, - displaySources = [], -}: { - className?: string; - messages: Message[]; - isLoading?: boolean; - displaySources?: RuntimeDisplaySource[]; +export function MessageGroup({ + className, + messages, + isLoading = false, + displaySources = [], + ragModeEnabled = false, +}: { + className?: string; + messages: Message[]; + isLoading?: boolean; + displaySources?: RuntimeDisplaySource[]; + ragModeEnabled?: boolean; }) { const { t } = useI18n(); const [showAbove, setShowAbove] = useState( @@ -110,9 +112,9 @@ export function MessageGroup({ [messages, writingModeEnabled], ); const { skills } = useSkills(); - const skillDisplayMap = useMemo( - () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [settings.display.ragModeEnabled, skills], + const skillDisplayMap = useMemo( + () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [ragModeEnabled, skills], ); const lastToolCallStep = useMemo(() => { const filteredSteps = steps.filter((step) => step.type === "toolCall"); @@ -207,7 +209,7 @@ export function MessageGroup({ {...step} isLoading={isLoading} skillDisplayMap={skillDisplayMap} - ragModeEnabled={settings.display.ragModeEnabled} + ragModeEnabled={ragModeEnabled} /> ), )} @@ -219,7 +221,7 @@ export function MessageGroup({ isLast={true} isLoading={isLoading} skillDisplayMap={skillDisplayMap} - ragModeEnabled={settings.display.ragModeEnabled} + ragModeEnabled={ragModeEnabled} /> )} diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 097a0baf..e5cae494 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -28,11 +28,12 @@ import { hasToolCalls, isHiddenFromUIMessage, } from "@/core/messages/utils"; -import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; -import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; -import { useSkills } from "@/core/skills/hooks"; -import { - buildSkillDisplayMap, +import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; +import { useSkills } from "@/core/skills/hooks"; +import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; +import { + buildSkillDisplayMap, collectRuntimeDisplaySources, type RuntimeDisplaySource, } from "@/core/skill-display/runtime"; @@ -299,10 +300,17 @@ export function MessageList({ } return map; }, [messages]); - - const [settings] = useLocalSettings(); - const ragModeEnabled = - settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + + const [settings] = useLocalSettings(); + const { data: citationDisplay } = useCitationDisplaySettings(); + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; + const knowledgeReferencesEnabled = + threadContext?.knowledgeReferencesEnabled === true; + const ragModeEnabled = + (referenceModeEntryEnabled && settings.display.ragModeEnabled) || + isThreadRagModeLocked(threadId) || + knowledgeReferencesEnabled; const hasSingleRoundRag = useMemo( () => messages.some( @@ -778,12 +786,13 @@ export function MessageList({ )) { if (hasReasoning(message)) { results.push( - , + , ); } results.push( @@ -831,11 +840,12 @@ export function MessageList({ .find((message) => message.type === "ai"); return (
- + void }) { const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); const cleanupWords = citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; useEffect(() => { setShortcutDraft( @@ -845,23 +847,45 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { aria-label="回答速度指示器" />
-
-
-
RAG 引用模式
-

- 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 -

-
- - setSettings("display", { ragModeEnabled: checked }) - } - aria-label="RAG 引用模式" - /> -
- {isAdmin && ( -
+ {isAdmin && ( +
+
+
参考文献模式入口
+

+ 管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。 +

+
+ + updateCitationDisplay.mutate({ + reference_mode_enabled: checked, + }) + } + aria-label="参考文献模式入口" + /> +
+ )} + {referenceModeEntryEnabled && ( +
+
+
RAG 引用模式
+

+ 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 +

+
+ + setSettings("display", { ragModeEnabled: checked }) + } + aria-label="RAG 引用模式" + /> +
+ )} + {isAdmin && ( +
参考文献去除词

diff --git a/frontend-web/src/components/workspace/thread-title.tsx b/frontend-web/src/components/workspace/thread-title.tsx index f6934e8f..8eb5af59 100644 --- a/frontend-web/src/components/workspace/thread-title.tsx +++ b/frontend-web/src/components/workspace/thread-title.tsx @@ -3,16 +3,18 @@ import { useEffect } from "react"; import { useI18n } from "@/core/i18n/hooks"; import type { AgentThreadState } from "@/core/threads"; -import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils"; -import { desensitize } from "@/lib/desensitize"; +import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; import { useThreadChat } from "./chats"; import { FlipDisplay } from "./flip-display"; -export function ThreadTitle({ - threadId, - thread, -}: { +export function ThreadTitle({ + className, + threadId, + thread, +}: { className?: string; threadId: string; thread: BaseStream; @@ -47,9 +49,9 @@ export function ThreadTitle({ if (!thread.values?.title) { return null; } - const visibleTitle = desensitize(displayTitle); - return ( - + const visibleTitle = desensitize(displayTitle); + return ( + {visibleTitle} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index 7614b40b..aa131c33 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -34,7 +34,9 @@ export function WorkspaceSidebar({ // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 - const isGeneralQa = pathname.startsWith("/page/workspace/chats"); + const isGeneralQa = + pathname.startsWith("/page/workspace/chats") || + pathname.startsWith("/page/strategy/chats"); if (forceCollapsed) { return ( diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 9409b02f..697c3025 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -73,12 +73,12 @@ export const sidebarMenuItems: MenuItem[] = [ // icon: Briefcase, // path: "/page/strategy/qa/position-roundtable", // }, - { - id: "dashboard-agent", - label: "大屏绘制", - icon: Gauge, - path: "/page/strategy/dashboard-agent", - }, + // { + // id: "dashboard-agent", + // label: "大屏绘制", + // icon: Gauge, + // path: "/page/strategy/dashboard-agent", + // }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, { id: "qa-zzjc", @@ -88,12 +88,12 @@ export const sidebarMenuItems: MenuItem[] = [ }, { id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, - { - id: "qa-research-chat", - label: "课题研究(对话版)", - icon: FileText, - path: "/page/canvas/ai-writing-chat", - }, + // { + // id: "qa-research-chat", + // label: "课题研究(对话版)", + // icon: FileText, + // path: "/page/canvas/ai-writing-chat", + // }, { id: "qa-deep-research", label: "深度研究", diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index bf59e850..24a212b0 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -6,6 +6,7 @@ export interface PromptPrefixSettings { enabled: boolean; switch_label: string; prompt_prefix: string; + ordinary_qa_markdown_format_enabled: boolean; } export interface PluginDomainAgentMappingDto { @@ -37,6 +38,7 @@ export interface PromptPrefixUpdate { enabled?: boolean; switch_label?: string; prompt_prefix?: string; + ordinary_qa_markdown_format_enabled?: boolean; } function fromPluginManagementDto(data: { @@ -173,10 +175,12 @@ export interface PrivateSquareUpdate { } export interface CitationDisplaySettings { + reference_mode_enabled: boolean; cleanup_words: string[]; } export interface CitationDisplayUpdate { + reference_mode_enabled?: boolean; cleanup_words?: string[]; } diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 47a81023..b6ad8102 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -16,9 +16,14 @@ import { rememberAskTime } from "../chat-timestamps"; import { getBackendBaseURL, getLangGraphBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; import { OptType, writeYLog } from "../log/y-log"; -import type { FileInMessage } from "../messages/utils"; -import type { LocalSettings } from "../settings"; -import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings"; +import type { FileInMessage } from "../messages/utils"; +import type { LocalSettings } from "../settings"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "../settings"; +import { useCitationDisplaySettings } from "../system-settings/hooks"; import { useUpdateSubtask } from "../tasks/context"; import type { UploadedFileInfo } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads"; @@ -566,8 +571,11 @@ export function useThreadStream({ onToolStart, onToolEnd, }: ThreadStreamOptions) { - const { t } = useI18n(); - const [localSettings] = useLocalSettings(); + const { t } = useI18n(); + const [localSettings] = useLocalSettings(); + const { data: citationDisplay } = useCitationDisplaySettings(); + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; // Track the thread ID that is currently streaming to handle thread changes during streaming const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); // Ref to track current thread ID across async callbacks without causing re-renders, @@ -1499,15 +1507,15 @@ export function useThreadStream({ ...context, ...extraContext, }; - const selectedLlmWikiIds = Array.isArray( - mergedContext.llmwiki_knowledge_base_ids, - ) - ? mergedContext.llmwiki_knowledge_base_ids - : []; - const ragModeEnabled = - localSettings.display.ragModeEnabled || - isThreadRagModeLocked(threadId) || - selectedLlmWikiIds.length > 0; + const selectedLlmWikiIds = Array.isArray( + mergedContext.llmwiki_knowledge_base_ids, + ) + ? mergedContext.llmwiki_knowledge_base_ids + : []; + const ragModeEnabled = + (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) || + isThreadRagModeLocked(threadId) || + selectedLlmWikiIds.length > 0; if (ragModeEnabled) { lockThreadRagMode(threadId); } @@ -1625,8 +1633,18 @@ export function useThreadStream({ sendInFlightRef.current = false; } }, - [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin], - ); + [ + thread, + _handleOnStart, + t.uploads.uploadingFiles, + context, + referenceModeEntryEnabled, + localSettings.display.ragModeEnabled, + queryClient, + runOptions, + confirmOrRejoin, + ], + ); const handleThreadStop = useCallback(async () => { const tid = threadIdRef.current; diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 3efc5f04..c5f24b6a 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -27,6 +27,18 @@ function pickAgentRef( return undefined; } +/** + * Return the agent that owns a thread when that ownership was persisted on + * either its run context or its metadata. Keeping this in one place ensures + * sidebars and route recovery make the same decision for older deep links. + */ +export function agentRefOfThread(thread: { + context?: ThreadAgentRef | null; + metadata?: Record | null; +}): string | undefined { + return pickAgentRef(thread.context, thread.metadata); +} + export function pathOfThread( thread: ThreadRouteTarget, context?: ThreadAgentRef | null, @@ -36,7 +48,7 @@ export function pathOfThread( const agentRef = typeof thread === "string" ? pickAgentRef(context, null) - : pickAgentRef(thread.context, thread.metadata); + : agentRefOfThread(thread) ?? pickAgentRef(context, null); return agentRef ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 1cf53d5a..7121f32b 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -3,6 +3,8 @@ import { BotIcon, Compass, + PanelLeftCloseIcon, + PanelLeftOpenIcon, PanelRightOpenIcon, PlusSquare, } from "lucide-react"; @@ -85,6 +87,7 @@ import { AGENTFX_REPORT_JSON_OUTPUT_PATH, notifyTaskReportSaved, } from "@/strategy-components/lib/agentfx-report-schema"; +import { cn } from "@/lib/utils"; import type { Message } from "@langchain/langgraph-sdk"; /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ @@ -225,6 +228,9 @@ export default function AgentChatPage({ const { t } = useI18n(); const [showFollowups, setShowFollowups] = useState(false); const router = useRouter(); + // 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar; + // 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。 + const [agentSidebarOpen, setAgentSidebarOpen] = useState(true); const { agent_id: agentIdFromPath } = useParams<{ agent_id: string; @@ -777,7 +783,13 @@ export default function AgentChatPage({ : "ready"; return ( - + 0, + }} + > {/* Root: fixed-height horizontal split. ChatBox (chat + artifact preview) lives in the left flex cell; the agent detail panel is a separate flex sibling OUTSIDE ChatBox, so the artifact preview @@ -788,7 +800,11 @@ export default function AgentChatPage({ {showAgentList && (

@@ -800,6 +816,25 @@ export default function AgentChatPage({
{/* Header in normal flow, fixed height. */}
+ {/* 智能体列表栏的本地收展入口。 */} + {showAgentList && !isEmbedded && ( + + )}
)} - - - {/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示 (展开态的「收起」按钮在面板右上角)。桌面端专用(lg+), 窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */} @@ -913,6 +931,23 @@ export default function AgentChatPage({ enabled={tokenUsageEnabled} messages={displayThread.messages} /> + + +
diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 9873ff47..4878610a 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -38,12 +38,16 @@ import { CompactModeGuide } from "@/components/workspace/compact-mode-guide"; import { Welcome } from "@/components/workspace/welcome"; import { CompactWelcome } from "@/components/workspace/compact-welcome"; import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; -import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; +import { + buildFullChatNavigationPath, + isChatsIframeWorkspacePath, +} from "@/core/page-layout/chats-iframe-route"; import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session"; import { isWujieEnv } from "@/core/embed/wujie"; import { useSidebar } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; -import { PanelLeftOpen } from "lucide-react"; +import { MessageSquarePlus, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; @@ -114,6 +118,7 @@ function clearBrowserContextParamsFromHash() { export default function ChatPage() { const routeBase = useRouteBase(); const router = useRouter(); + const navigate = useNavigate(); const searchParams = useSearchParams(); const pathname = usePathname(); // `chatMode=full` is used by an embedded standard chat. It intentionally @@ -671,6 +676,20 @@ export default function ChatPage() { chromeByPreference && !isChatsIframeWorkspacePath(pathname); const compactLanding = isLandingState && compactChrome; const compactActionsHidden = isHideChromeActive(); + const headerNewChatPath = useMemo( + () => + buildFullChatNavigationPath( + `${routeBase}/chats/new`, + searchParams.toString(), + ), + [routeBase, searchParams], + ); + const handleHeaderNewChat = useCallback(() => { + navigate(headerNewChatPath, { + replace: true, + state: { resetAt: Date.now() }, + }); + }, [headerNewChatPath, navigate]); // 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入 // (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有 // 全局 chrome,但不该在外部系统里弹引导。 @@ -684,7 +703,14 @@ export default function ChatPage() { !isWujieEnv(); return ( - + 0, + }} + >
- -
-
- {/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} - {compactChrome && !historySidebarOpen && !compactActionsHidden && ( + {/* 左上角始终提供左栏切换;展开态显示“收起”,收起态显示“展开”。 */} + {compactChrome && !compactActionsHidden && ( )} + +
+
+ {compactChrome && !compactActionsHidden && ( + + )} @@ -790,11 +836,10 @@ export default function ChatPage() { isLandingState && !compactLanding && "-translate-y-[calc(50vh-96px)]", - compactLanding - ? "max-w-[calc(var(--container-width-xl)+150px)]" - : isLandingState - ? "max-w-[calc(var(--container-width-md)+150px)]" - : "max-w-(--container-width-lg)", + // 新对话落地态在全量与简洁模式使用同一输入框宽度。 + isLandingState + ? "max-w-[calc(var(--container-width-md)+150px)]" + : "max-w-(--container-width-lg)", )} >
diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index cd1f29b7..fbf72c3e 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -39,8 +39,14 @@ import { useLocalSettings, useThreadSettings, } from "@/core/settings"; -import { usePluginManagementSettings } from "@/core/system-settings/hooks"; -import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; +import { + useCitationDisplaySettings, + usePluginManagementSettings, +} from "@/core/system-settings/hooks"; +import { + stopActiveThreadBeforeClarificationReply, + useThreadStream, +} from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { env } from "@/env"; @@ -379,6 +385,9 @@ export default function IframeChatPage() { }, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]); const ragModeLocked = isThreadRagModeLocked(threadId); + const { data: citationDisplay } = useCitationDisplaySettings(); + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const contextCompressionEnabled = @@ -525,6 +534,7 @@ export default function IframeChatPage() { memoryInjectionEnabled: Boolean( settings.context.memory_injection_enabled ?? true, ), + referenceModeEntryEnabled, ragModeEnabled, ragModeLocked, contextCompressionEnabled, diff --git a/frontend-web/src/pages/PromptPrefixSettingsPage.tsx b/frontend-web/src/pages/PromptPrefixSettingsPage.tsx index 65e722c9..55c4af60 100644 --- a/frontend-web/src/pages/PromptPrefixSettingsPage.tsx +++ b/frontend-web/src/pages/PromptPrefixSettingsPage.tsx @@ -46,6 +46,8 @@ export default function PromptPrefixSettingsPage({ const [enabled, setEnabled] = useState(false); const [switchLabel, setSwitchLabel] = useState(""); const [promptPrefix, setPromptPrefix] = useState(""); + const [ordinaryQaMarkdownFormatEnabled, setOrdinaryQaMarkdownFormatEnabled] = + useState(false); // Only seed the draft from server data once. Re-syncing every time react-query // refetches (window focus, route remount, etc.) would silently overwrite the // user's in-progress edits — and made the toggle look unresponsive: a click @@ -57,6 +59,9 @@ export default function PromptPrefixSettingsPage({ setEnabled(data.enabled); setSwitchLabel(data.switch_label); setPromptPrefix(data.prompt_prefix); + setOrdinaryQaMarkdownFormatEnabled( + data.ordinary_qa_markdown_format_enabled === true, + ); initializedRef.current = true; }, [data]); @@ -79,6 +84,8 @@ export default function PromptPrefixSettingsPage({ enabled, switch_label: switchLabel, prompt_prefix: promptPrefix, + ordinary_qa_markdown_format_enabled: + ordinaryQaMarkdownFormatEnabled, }); toast.success("已保存",darkToastOpts.success); } catch (e) { @@ -95,10 +102,9 @@ export default function PromptPrefixSettingsPage({ } >
-

问答前缀配置

+

问答提示词配置

- 管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时, - 会将下方配置的前缀文本和原问题拼接后再发送给模型。 + 管理员可在此配置问答前缀和普通问答的固定 Markdown 格式提示词。

@@ -147,6 +153,21 @@ export default function PromptPrefixSettingsPage({ />
+
+
+
固定格式提示词
+

+ 开启后,普通问答的长篇内容会按“# 主标题、## 一、标题、### + (一) 标题、#### 1. 标题”的固定 Markdown 格式生成。默认关闭。 +

+
+ +
+
-
+
@@ -347,14 +347,14 @@ export function WorkflowConfigDialog({ value={titleFilter} onChange={(event) => setTitleFilter(event.target.value)} placeholder="按标题或检索词筛选..." - className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 sm:text-sm" + className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-100 dark:focus-visible:border-[#2b8ffe] dark:focus-visible:ring-[#2b8ffe]/25 sm:text-sm" /> {titleFilter && ( @@ -366,7 +366,7 @@ export function WorkflowConfigDialog({ value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as ReportStructureStatusFilter)} aria-label="启用状态筛选" - className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm" + className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm" > @@ -380,7 +380,7 @@ export function WorkflowConfigDialog({ value={modeFilter} onChange={(event) => setModeFilter(event.target.value as "all" | ReportStructureMode)} aria-label="大纲类型筛选" - className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm" + className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm" > @@ -391,11 +391,11 @@ export function WorkflowConfigDialog({
- + {visible.length} 套
) : ( -
+
-
+
@@ -420,8 +420,8 @@ export function WorkflowConfigDialog({ {detail.title} {detail.id === appliedStructureId && ( - - + + 当前使用 )} @@ -440,7 +440,7 @@ export function WorkflowConfigDialog({
) : ( -
-