This commit is contained in:
董宏凯 committed 2026-09-01 15:36:47 +08:00
commit 553f72604a
33 files changed
+1050 -201

No files matched your search

@@ -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/<sandboxId>/| 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/<sandboxId>/` |
| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry |
| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 |
| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 |
### 4.1 预览路由规则
暂时没有可用泛域名时,使用路径型预览:
```text
http(s)://<服务器地址>:8022/preview/<sandboxId>/
```
项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。
## 5. 镜像、模板与离线依赖策略
### 5.1 标准镜像
第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile:
| 镜像 | 用途 | 预置内容 |
|---|---|---|
| `appbuilder/visual-ui:<version>` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 |
| `appbuilder/visual-fullstack:<version>` | 前端加接口 | 上述前端依赖 + 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/<sandboxId>/` 代理到对应内部容器;支持 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 后再作为受控的内部多人平台发布。
@@ -60,7 +60,6 @@ import {
createContext, createContext,
type FormEvent, type FormEvent,
type FormEventHandler, type FormEventHandler,
Fragment,
type HTMLAttributes, type HTMLAttributes,
type KeyboardEventHandler, type KeyboardEventHandler,
type PropsWithChildren, type PropsWithChildren,
@@ -124,8 +123,10 @@ export const usePromptInputController = () => {
return ctx; return ctx;
}; };
// Optional variants (do NOT throw). Useful for dual-mode components. // Optional variants (do NOT throw). Useful for dual-mode components and for
const useOptionalPromptInputController = () => // Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController); useContext(PromptInputController);
export const useProviderAttachments = () => { export const useProviderAttachments = () => {
@@ -406,9 +407,9 @@ export function PromptInputAttachments({
{...props} {...props}
> >
{attachments.files.map((file) => ( {attachments.files.map((file) => (
<Fragment key={file.id}> <div key={file.id} className="max-w-60">
<div className="max-w-60">{children(file)}</div> {children(file)}
</Fragment> </div>
))} ))}
</div> </div>
); );
@@ -22,6 +22,7 @@ import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-f
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
@@ -87,7 +88,11 @@ const ChatBox: React.FC<{
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
}) => { }) => {
const { isMock, thread } = useThread(); const {
isMock,
thread,
knowledgeReferencesEnabled = false,
} = useThread();
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0); const artifactsCountRef = useRef(0);
@@ -106,8 +111,13 @@ const ChatBox: React.FC<{
const writingModeEnabled = !!settings.context.writing_mode; const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled = 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 // Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference // the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs. // batches through DeerFlow's backend APIs.
@@ -121,7 +131,7 @@ const ChatBox: React.FC<{
); );
const referencesEnabled = ragModeEnabled || hasFrontendRag; const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches = const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag; referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
const { skills } = useSkills(); const { skills } = useSkills();
const canScanArtifactDirectory = const canScanArtifactDirectory =
@@ -1,7 +1,7 @@
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react"; 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"; import { useI18n } from "@/core/i18n/hooks";
/** /**
@@ -11,7 +11,11 @@ export function useSpecificChatMode() {
const { t } = useI18n(); const { t } = useI18n();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams(); 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(() => { const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") { if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined; return undefined;
@@ -19,8 +23,10 @@ export function useSpecificChatMode() {
return t.inputBox.createSkillPrompt; return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]); }, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined); const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput); const setInputRef = useRef<((value: string) => void) | undefined>(
setInputRef.current = promptInputController.textInput.setInput; promptInputController?.textInput.setInput,
);
setInputRef.current = promptInputController?.textInput.setInput;
useEffect(() => { useEffect(() => {
if ( if (
inputInitialValue && inputInitialValue &&
@@ -28,7 +34,10 @@ export function useSpecificChatMode() {
) { ) {
lastInitialValueRef.current = inputInitialValue; lastInitialValueRef.current = inputInitialValue;
setTimeout(() => { setTimeout(() => {
setInputRef.current(inputInitialValue); const setInput = setInputRef.current;
if (!setInput) return;
setInput(inputInitialValue);
const textarea = document.querySelector("textarea"); const textarea = document.querySelector("textarea");
if (textarea) { if (textarea) {
textarea.focus(); textarea.focus();
@@ -7,7 +7,8 @@
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难 * 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。 * 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
* *
* 三块结构(自上而下): * 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
* 决定。三块结构(自上而下):
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮; * 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条); * 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单: * 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
@@ -85,6 +86,7 @@ import {
titleOfThread, titleOfThread,
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route"; import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
@@ -93,11 +95,6 @@ import { cn } from "@/lib/utils";
import { agentColor } from "./agent-icon-color"; import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header"; 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; const MAX_FREQUENT = 4;
@@ -142,6 +139,7 @@ function SectionLabel({
export function GeneralQaSidebar() { export function GeneralQaSidebar() {
const { t } = useI18n(); const { t } = useI18n();
const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。 // 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
@@ -188,9 +186,14 @@ export function GeneralQaSidebar() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent"); const activeAgentId = searchParams.get("agent");
const currentSearch = searchParams.toString(); const currentSearch = searchParams.toString();
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch); const chatsBasePath = `${routeBase}/chats`;
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch); const newChatPath = `${chatsBasePath}/new`;
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch); const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(
`${routeBase}/agents`,
currentSearch,
);
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new, // 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。 // 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
@@ -199,19 +202,18 @@ export function GeneralQaSidebar() {
(agentId: string) => { (agentId: string) => {
navigate( navigate(
buildFullChatNavigationPath( buildFullChatNavigationPath(
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`, routeBase === "/page/workspace"
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch, currentSearch,
), ),
); );
}, },
[currentSearch, navigate], [currentSearch, navigate, newChatPath, routeBase],
); );
// ── 最近的对话取数 + 搜索 ── // ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{ const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads(); const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread(); const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread(); const { mutate: renameThread } = useRenameThread();
@@ -224,6 +226,14 @@ export function GeneralQaSidebar() {
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null); const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const pathForThread = useCallback(
(thread: AgentThread) =>
isBootstrapThread(thread)
? listPathOfThread(thread)
: pathOfThread(thread, undefined, routeBase),
[routeBase],
);
const visibleThreads = useMemo( const visibleThreads = useMemo(
() => () =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
@@ -254,21 +264,22 @@ export function GeneralQaSidebar() {
deleteThread({ threadId }); deleteThread({ threadId });
if (threadId === threadIdFromPath) { if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((th) => th.thread_id === threadId); const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); let nextThreadPath = newChatPath;
if (threadIndex > -1) { if (threadIndex > -1) {
if (threads[threadIndex + 1]) { if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); nextThreadPath = pathForThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) { } else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); nextThreadPath = pathForThread(threads[threadIndex - 1]!);
} }
} }
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch)); void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
} }
}, },
[ [
agentIdFromPath,
currentSearch, currentSearch,
deleteThread, deleteThread,
newChatPath,
pathForThread,
router, router,
threadIdFromPath, threadIdFromPath,
threads, threads,
@@ -467,7 +478,7 @@ export function GeneralQaSidebar() {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const href = buildFullChatNavigationPath( const href = buildFullChatNavigationPath(
listPathOfThread(thread), pathForThread(thread),
currentSearch, currentSearch,
); );
const isActive = href === pathname; const isActive = href === pathname;
@@ -98,7 +98,7 @@ import {
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
@@ -143,6 +143,7 @@ export function InputBox({
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
hideContextTools, hideContextTools,
hideMemoryTool = true,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -153,6 +154,11 @@ export function InputBox({
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等 /** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */ * 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean; hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean; disabled?: boolean;
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
@@ -224,6 +230,9 @@ export function InputBox({
const { thread, isMock } = useThread(); const { thread, isMock } = useThread();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -804,7 +813,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip {!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={ content={
memoryInjectionEnabled memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆" ? "记忆注入已开启:对话前会注入历史记忆"
@@ -830,7 +839,7 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示" ? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -7,6 +7,12 @@ import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType { export interface ThreadContextType {
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
isMock?: boolean; 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. * When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated * Artifact/image URLs then resolve to the unauthenticated
@@ -88,11 +88,13 @@ export function MessageGroup({
messages, messages,
isLoading = false, isLoading = false,
displaySources = [], displaySources = [],
ragModeEnabled = false,
}: { }: {
className?: string; className?: string;
messages: Message[]; messages: Message[];
isLoading?: boolean; isLoading?: boolean;
displaySources?: RuntimeDisplaySource[]; displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [showAbove, setShowAbove] = useState( const [showAbove, setShowAbove] = useState(
@@ -111,8 +113,8 @@ export function MessageGroup({
); );
const { skills } = useSkills(); const { skills } = useSkills();
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills], [ragModeEnabled, skills],
); );
const lastToolCallStep = useMemo(() => { const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall"); const filteredSteps = steps.filter((step) => step.type === "toolCall");
@@ -207,7 +209,7 @@ export function MessageGroup({
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
)} )}
@@ -219,7 +221,7 @@ export function MessageGroup({
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
@@ -31,6 +31,7 @@ import {
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { import {
buildSkillDisplayMap, buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
@@ -301,8 +302,15 @@ export function MessageList({
}, [messages]); }, [messages]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const ragModeEnabled = const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); (referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo( const hasSingleRoundRag = useMemo(
() => () =>
messages.some( messages.some(
@@ -783,6 +791,7 @@ export function MessageList({
messages={[message]} messages={[message]}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)} displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>, />,
); );
} }
@@ -835,6 +844,7 @@ export function MessageList({
messages={group.messages} messages={group.messages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)} displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -205,6 +205,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
useEffect(() => { useEffect(() => {
setShortcutDraft( setShortcutDraft(
@@ -845,21 +847,43 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
aria-label="回答速度指示器" aria-label="回答速度指示器"
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4"> {isAdmin && (
<div> <div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div className="text-sm font-medium">RAG 引用模式</div> <div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed"> <div className="text-sm font-medium">参考文献模式入口</div>
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 <p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
</p> 管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
</p>
</div>
<Switch
checked={referenceModeEntryEnabled}
disabled={updateCitationDisplay.isPending}
onCheckedChange={(checked) =>
updateCitationDisplay.mutate({
reference_mode_enabled: checked,
})
}
aria-label="参考文献模式入口"
/>
</div> </div>
<Switch )}
checked={settings.display.ragModeEnabled} {referenceModeEntryEnabled && (
onCheckedChange={(checked) => <div className="flex items-start justify-between gap-4 rounded-lg border p-4">
setSettings("display", { ragModeEnabled: checked }) <div>
} <div className="text-sm font-medium">RAG 引用模式</div>
aria-label="RAG 引用模式" <p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
/> 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</div> </p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
)}
{isAdmin && ( {isAdmin && (
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<div className="mb-3"> <div className="mb-3">
@@ -5,11 +5,13 @@ import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils"; import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useThreadChat } from "./chats"; import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display"; import { FlipDisplay } from "./flip-display";
export function ThreadTitle({ export function ThreadTitle({
className,
threadId, threadId,
thread, thread,
}: { }: {
@@ -49,7 +51,7 @@ export function ThreadTitle({
} }
const visibleTitle = desensitize(displayTitle); const visibleTitle = desensitize(displayTitle);
return ( return (
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full"> <FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
<span className="block truncate" title={visibleTitle}> <span className="block truncate" title={visibleTitle}>
{visibleTitle} {visibleTitle}
</span> </span>
@@ -34,7 +34,9 @@ export function WorkspaceSidebar({
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats"); const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
if (forceCollapsed) { if (forceCollapsed) {
return ( return (
@@ -73,12 +73,12 @@ export const sidebarMenuItems: MenuItem[] = [
// icon: Briefcase, // icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable", // path: "/page/strategy/qa/position-roundtable",
// }, // },
{ // {
id: "dashboard-agent", // id: "dashboard-agent",
label: "大屏绘制", // label: "大屏绘制",
icon: Gauge, // icon: Gauge,
path: "/page/strategy/dashboard-agent", // path: "/page/strategy/dashboard-agent",
}, // },
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{ {
id: "qa-zzjc", 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-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
{ // {
id: "qa-research-chat", // id: "qa-research-chat",
label: "课题研究(对话版)", // label: "课题研究(对话版)",
icon: FileText, // icon: FileText,
path: "/page/canvas/ai-writing-chat", // path: "/page/canvas/ai-writing-chat",
}, // },
{ {
id: "qa-deep-research", id: "qa-deep-research",
label: "深度研究", label: "深度研究",
@@ -6,6 +6,7 @@ export interface PromptPrefixSettings {
enabled: boolean; enabled: boolean;
switch_label: string; switch_label: string;
prompt_prefix: string; prompt_prefix: string;
ordinary_qa_markdown_format_enabled: boolean;
} }
export interface PluginDomainAgentMappingDto { export interface PluginDomainAgentMappingDto {
@@ -37,6 +38,7 @@ export interface PromptPrefixUpdate {
enabled?: boolean; enabled?: boolean;
switch_label?: string; switch_label?: string;
prompt_prefix?: string; prompt_prefix?: string;
ordinary_qa_markdown_format_enabled?: boolean;
} }
function fromPluginManagementDto(data: { function fromPluginManagementDto(data: {
@@ -173,10 +175,12 @@ export interface PrivateSquareUpdate {
} }
export interface CitationDisplaySettings { export interface CitationDisplaySettings {
reference_mode_enabled: boolean;
cleanup_words: string[]; cleanup_words: string[];
} }
export interface CitationDisplayUpdate { export interface CitationDisplayUpdate {
reference_mode_enabled?: boolean;
cleanup_words?: string[]; cleanup_words?: string[];
} }
+21 -3
View File
@@ -18,7 +18,12 @@ import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log"; import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings"; import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -568,6 +573,9 @@ export function useThreadStream({
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings(); 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 // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // Ref to track current thread ID across async callbacks without causing re-renders,
@@ -1505,7 +1513,7 @@ export function useThreadStream({
? mergedContext.llmwiki_knowledge_base_ids ? mergedContext.llmwiki_knowledge_base_ids
: []; : [];
const ragModeEnabled = const ragModeEnabled =
localSettings.display.ragModeEnabled || (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) || isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0; selectedLlmWikiIds.length > 0;
if (ragModeEnabled) { if (ragModeEnabled) {
@@ -1625,7 +1633,17 @@ export function useThreadStream({
sendInFlightRef.current = false; 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 handleThreadStop = useCallback(async () => {
+13 -1
View File
@@ -27,6 +27,18 @@ function pickAgentRef(
return undefined; 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<string, unknown> | null;
}): string | undefined {
return pickAgentRef(thread.context, thread.metadata);
}
export function pathOfThread( export function pathOfThread(
thread: ThreadRouteTarget, thread: ThreadRouteTarget,
context?: ThreadAgentRef | null, context?: ThreadAgentRef | null,
@@ -36,7 +48,7 @@ export function pathOfThread(
const agentRef = const agentRef =
typeof thread === "string" typeof thread === "string"
? pickAgentRef(context, null) ? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata); : agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
+54 -19
View File
@@ -3,6 +3,8 @@
import { import {
BotIcon, BotIcon,
Compass, Compass,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PanelRightOpenIcon, PanelRightOpenIcon,
PlusSquare, PlusSquare,
} from "lucide-react"; } from "lucide-react";
@@ -85,6 +87,7 @@ import {
AGENTFX_REPORT_JSON_OUTPUT_PATH, AGENTFX_REPORT_JSON_OUTPUT_PATH,
notifyTaskReportSaved, notifyTaskReportSaved,
} from "@/strategy-components/lib/agentfx-report-schema"; } from "@/strategy-components/lib/agentfx-report-schema";
import { cn } from "@/lib/utils";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
@@ -225,6 +228,9 @@ export default function AgentChatPage({
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
const [agentSidebarOpen, setAgentSidebarOpen] = useState(true);
const { agent_id: agentIdFromPath } = useParams<{ const { agent_id: agentIdFromPath } = useParams<{
agent_id: string; agent_id: string;
@@ -777,7 +783,13 @@ export default function AgentChatPage({
: "ready"; : "ready";
return ( return (
<ThreadContext.Provider value={{ thread: displayThread }}> <ThreadContext.Provider
value={{
thread: displayThread,
knowledgeReferencesEnabled:
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
}}
>
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact {/* Root: fixed-height horizontal split. ChatBox (chat + artifact
preview) lives in the left flex cell; the agent detail panel is a preview) lives in the left flex cell; the agent detail panel is a
separate flex sibling OUTSIDE ChatBox, so the artifact preview separate flex sibling OUTSIDE ChatBox, so the artifact preview
@@ -788,7 +800,11 @@ export default function AgentChatPage({
{showAgentList && ( {showAgentList && (
<div <div
data-agent-guide="chat-sidebar" data-agent-guide="chat-sidebar"
className="flex h-full shrink-0" aria-hidden={!agentSidebarOpen}
className={cn(
"flex h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
agentSidebarOpen ? "w-(--sidebar-width)" : "pointer-events-none w-0",
)}
> >
<AgentChatSidebar activeAgentId={agent_id} /> <AgentChatSidebar activeAgentId={agent_id} />
</div> </div>
@@ -800,6 +816,25 @@ export default function AgentChatPage({
<main className="relative flex size-full min-h-0 min-w-0 flex-col"> <main className="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Header in normal flow, fixed height. */} {/* Header in normal flow, fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md"> <header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
{/* 智能体列表栏的本地收展入口。 */}
{showAgentList && !isEmbedded && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setAgentSidebarOpen((open) => !open)}
title={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-label={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-expanded={agentSidebarOpen}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{agentSidebarOpen ? (
<PanelLeftCloseIcon className="size-4" />
) : (
<PanelLeftOpenIcon className="size-4" />
)}
</Button>
)}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1"> <div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" /> <BotIcon className="text-primary h-3.5 w-3.5" />
<span <span
@@ -875,23 +910,6 @@ export default function AgentChatPage({
开始回答 开始回答
</Button> </Button>
)} )}
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示 {/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+), (展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */} 窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
@@ -913,6 +931,23 @@ export default function AgentChatPage({
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
messages={displayThread.messages} messages={displayThread.messages}
/> />
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
</div> </div>
+62 -17
View File
@@ -38,12 +38,16 @@ import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome"; import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; 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 { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; 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 { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
@@ -114,6 +118,7 @@ function clearBrowserContextParamsFromHash() {
export default function ChatPage() { export default function ChatPage() {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const router = useRouter(); const router = useRouter();
const navigate = useNavigate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname(); const pathname = usePathname();
// `chatMode=full` is used by an embedded standard chat. It intentionally // `chatMode=full` is used by an embedded standard chat. It intentionally
@@ -671,6 +676,20 @@ export default function ChatPage() {
chromeByPreference && !isChatsIframeWorkspacePath(pathname); chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; const compactLanding = isLandingState && compactChrome;
const compactActionsHidden = isHideChromeActive(); 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 嵌入 // 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有 // (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
// 全局 chrome,但不该在外部系统里弹引导。 // 全局 chrome,但不该在外部系统里弹引导。
@@ -684,7 +703,14 @@ export default function ChatPage() {
!isWujieEnv(); !isWujieEnv();
return ( return (
<ThreadContext.Provider value={{ thread: displayThread, isMock }}> <ThreadContext.Provider
value={{
thread: displayThread,
isMock,
knowledgeReferencesEnabled:
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
}}
>
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
<div <div
className={cn( className={cn(
@@ -702,27 +728,47 @@ export default function ChatPage() {
)} )}
> >
<div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium"> <div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
<ThreadTitle threadId={threadId} thread={displayThread} /> {/* 左上角始终提供左栏切换;展开态显示“收起”,收起态显示“展开”。 */}
</div> {compactChrome && !compactActionsHidden && (
<div className="flex shrink-0 items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={toggleHistorySidebar} onClick={toggleHistorySidebar}
title="打开历史记录" title={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-label="打开历史记录" aria-label={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
className="text-muted-foreground hover:text-foreground" className="mr-1 shrink-0 text-muted-foreground hover:text-foreground"
> >
<PanelLeftOpen className="size-4" /> {historySidebarOpen ? (
<PanelLeftClose className="size-4" />
) : (
<PanelLeftOpen className="size-4" />
)}
</Button> </Button>
)} )}
<ThreadTitle
className="-translate-y-1"
threadId={threadId}
thread={displayThread}
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<TokenUsageIndicator <TokenUsageIndicator
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
messages={displayThread.messages} messages={displayThread.messages}
/> />
{compactChrome && !compactActionsHidden && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleHeaderNewChat}
className="text-muted-foreground hover:text-foreground"
>
<MessageSquarePlus className="size-4" />
新建对话
</Button>
)}
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} /> <KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
@@ -790,11 +836,10 @@ export default function ChatPage() {
isLandingState && isLandingState &&
!compactLanding && !compactLanding &&
"-translate-y-[calc(50vh-96px)]", "-translate-y-[calc(50vh-96px)]",
compactLanding // 新对话落地态在全量与简洁模式使用同一输入框宽度。
? "max-w-[calc(var(--container-width-xl)+150px)]" isLandingState
: isLandingState ? "max-w-[calc(var(--container-width-md)+150px)]"
? "max-w-[calc(var(--container-width-md)+150px)]" : "max-w-(--container-width-lg)",
: "max-w-(--container-width-lg)",
)} )}
> >
<div className="absolute -top-4 right-0 left-0 z-0"> <div className="absolute -top-4 right-0 left-0 z-0">
+12 -2
View File
@@ -39,8 +39,14 @@ import {
useLocalSettings, useLocalSettings,
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks"; import {
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; useCitationDisplaySettings,
usePluginManagementSettings,
} from "@/core/system-settings/hooks";
import {
stopActiveThreadBeforeClarificationReply,
useThreadStream,
} from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env"; import { env } from "@/env";
@@ -379,6 +385,9 @@ export default function IframeChatPage() {
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]); }, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeLocked = isThreadRagModeLocked(threadId);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled = const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked; localSettings.display.ragModeEnabled || ragModeLocked;
const contextCompressionEnabled = const contextCompressionEnabled =
@@ -525,6 +534,7 @@ export default function IframeChatPage() {
memoryInjectionEnabled: Boolean( memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true, settings.context.memory_injection_enabled ?? true,
), ),
referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
contextCompressionEnabled, contextCompressionEnabled,
@@ -46,6 +46,8 @@ export default function PromptPrefixSettingsPage({
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState(""); const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState(""); const [promptPrefix, setPromptPrefix] = useState("");
const [ordinaryQaMarkdownFormatEnabled, setOrdinaryQaMarkdownFormatEnabled] =
useState(false);
// Only seed the draft from server data once. Re-syncing every time react-query // Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the // refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click // user's in-progress edits — and made the toggle look unresponsive: a click
@@ -57,6 +59,9 @@ export default function PromptPrefixSettingsPage({
setEnabled(data.enabled); setEnabled(data.enabled);
setSwitchLabel(data.switch_label); setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix); setPromptPrefix(data.prompt_prefix);
setOrdinaryQaMarkdownFormatEnabled(
data.ordinary_qa_markdown_format_enabled === true,
);
initializedRef.current = true; initializedRef.current = true;
}, [data]); }, [data]);
@@ -79,6 +84,8 @@ export default function PromptPrefixSettingsPage({
enabled, enabled,
switch_label: switchLabel, switch_label: switchLabel,
prompt_prefix: promptPrefix, prompt_prefix: promptPrefix,
ordinary_qa_markdown_format_enabled:
ordinaryQaMarkdownFormatEnabled,
}); });
toast.success("已保存",darkToastOpts.success); toast.success("已保存",darkToastOpts.success);
} catch (e) { } catch (e) {
@@ -95,10 +102,9 @@ export default function PromptPrefixSettingsPage({
} }
> >
<div> <div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答前缀配置</h1> <h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答提示词配置</h1>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时, 管理员可在此配置问答前缀和普通问答的固定 Markdown 格式提示词。
会将下方配置的前缀文本和原问题拼接后再发送给模型。
</p> </p>
</div> </div>
@@ -147,6 +153,21 @@ export default function PromptPrefixSettingsPage({
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">固定格式提示词</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,普通问答的长篇内容会按“# 主标题、## 一、标题、###
(一) 标题、#### 1. 标题”的固定 Markdown 格式生成。默认关闭。
</p>
</div>
<Switch
checked={ordinaryQaMarkdownFormatEnabled}
onCheckedChange={setOrdinaryQaMarkdownFormatEnabled}
aria-label="固定格式提示词"
/>
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}> <Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && ( {update.isPending && (
+21 -6
View File
@@ -1,7 +1,8 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config"; import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { RouteBaseProvider } from "@/core/route-base"; import { RouteBaseProvider } from "@/core/route-base";
@@ -79,6 +80,24 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes"; import { ChatRuntime } from "./WorkspaceRoutes";
/**
* Strategy's legacy `/chats/:thread_id` route shares ChatPage's header.
* Unlike WorkspaceRoutes it did not render a WorkspaceSidebar, leaving the
* header's expand/collapse control with no visible panel to operate.
*/
function StrategyChatThreadLayout() {
return (
<div className="flex size-full min-h-0 overflow-hidden">
<WorkspaceSidebar compactChrome />
<SidebarInset className="min-w-0">
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</SidebarInset>
</div>
);
}
export default function StrategyRoutes() { export default function StrategyRoutes() {
const storedAuth = getStoredAuth(); const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -342,11 +361,7 @@ export default function StrategyRoutes() {
<Route path="chats" element={<ChatsPage />} /> <Route path="chats" element={<ChatsPage />} />
<Route <Route
path="chats/:thread_id" path="chats/:thread_id"
element={ element={<StrategyChatThreadLayout />}
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/> />
<Route path="agents" element={<AgentsPage />} /> <Route path="agents" element={<AgentsPage />} />
+77 -3
View File
@@ -1,7 +1,8 @@
import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth'; import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config'; import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import { import {
resolveChatsIframeEntryRedirect, resolveChatsIframeEntryRedirect,
@@ -25,6 +26,7 @@ import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context'; import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base'; import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import ChatsPage from './ChatsPage'; import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage'; import ChatPage from './ChatPage';
@@ -180,7 +182,11 @@ export function WorkspaceLayout({
'--header-height': headerHidden ? '0rem' : '4rem', '--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties } as CSSProperties
} }
key={String(compactWorkspaceSidebar)} // Agent and general-chat routes share this layout component. Keep their
// sidebar state separate: otherwise React preserves the collapsed agent
// state when a history item switches to `/chats/:thread_id`, while the
// new page's header button is meant to control GeneralQaSidebar.
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={ defaultOpen={
forceWorkspaceSidebarCollapsed forceWorkspaceSidebarCollapsed
@@ -341,12 +347,80 @@ function ChatThreadEntryRoute({
return ( return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <AgentAwareChatThreadContent />
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
); );
} }
/**
* Older bookmarks and a few historic list entries use the generic
* ``/chats/:thread_id`` path even for an agent-owned conversation. Recover the
* owning agent from the persisted thread record before rendering so those
* links keep the agent workspace (agent list and detail panel) instead of
* falling back to the general Q&A page. Task workspace threads intentionally
* remain on their explicit task routes because their surrounding layout is
* part of the task context.
*/
function AgentAwareChatThreadContent() {
const { thread_id: threadId } = useParams<{ thread_id: string }>();
const location = useLocation();
const [agentId, setAgentId] = useState<string | null>(null);
const [resolved, setResolved] = useState(false);
useEffect(() => {
let cancelled = false;
setAgentId(null);
setResolved(false);
if (!threadId) {
setResolved(true);
return () => {
cancelled = true;
};
}
void getAPIClient()
.threads.get(threadId)
.then((thread) => {
if (cancelled) return;
const record = thread as {
context?: { agent_id?: string; agent_name?: string } | null;
metadata?: Record<string, unknown> | null;
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
})
.catch(() => {
if (!cancelled) setAgentId(null);
})
.finally(() => {
if (!cancelled) setResolved(true);
});
return () => {
cancelled = true;
};
}, [threadId]);
if (!resolved) {
return <div className="size-full bg-background" aria-busy="true" />;
}
if (threadId && agentId) {
return (
<Navigate
replace
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/>
);
}
return <ChatPage />;
}
/** /**
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe` * `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding * and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
@@ -155,12 +155,12 @@ export function ResearchSandboxPanel({
/> />
<ResizablePanel <ResizablePanel
className={cn( className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out", "bg-[#fffdfa] transition-all duration-300 ease-in-out dark:bg-[#0b1426]",
!open && "opacity-0", !open && "opacity-0",
)} )}
id="artifacts" id="artifacts"
> >
<div className="relative h-full bg-[#fffdfa] p-4"> <div className="relative h-full bg-[#fffdfa] p-4 dark:bg-[#0b1426]">
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? ( {selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<> <>
{documentRewriteActive && onCancelDocumentRewrite && ( {documentRewriteActive && onCancelDocumentRewrite && (
@@ -114,7 +114,7 @@ const outlineMarkdownClassName =
"[&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1 " + "[&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " + "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_strong]:font-semibold [&_strong]:text-neutral-900 dark:[&_strong]:text-slate-100 " + "[&_strong]:font-semibold [&_strong]:text-neutral-900 dark:[&_strong]:text-slate-100 " +
"[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-rose-900/50 dark:[&_blockquote]:text-slate-400 " + "[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-[#2b8ffe]/45 dark:[&_blockquote]:text-slate-400 " +
"[&_code]:rounded [&_code]:border [&_code]:bg-neutral-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs dark:[&_code]:border-slate-700 dark:[&_code]:bg-slate-800 " + "[&_code]:rounded [&_code]:border [&_code]:bg-neutral-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs dark:[&_code]:border-slate-700 dark:[&_code]:bg-slate-800 " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-neutral-50 [&_pre]:p-4 dark:[&_pre]:border-slate-700 dark:[&_pre]:bg-slate-800 " + "[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-neutral-50 [&_pre]:p-4 dark:[&_pre]:border-slate-700 dark:[&_pre]:bg-slate-800 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0"; "[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
@@ -309,17 +309,17 @@ export function WorkflowConfigDialog({
overlayClassName="bg-slate-900/60 backdrop-blur-xs" overlayClassName="bg-slate-900/60 backdrop-blur-xs"
className={ className={
!detail !detail
? "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-3xl border border-slate-200/80 bg-white p-0 shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:max-w-6xl" ? "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-3xl border border-slate-200/80 bg-white p-0 shadow-2xl dark:border-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
: "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-2xl border border-neutral-200/90 bg-white p-0 shadow-2xl shadow-neutral-950/20 dark:border-slate-800 dark:bg-slate-900 sm:max-w-6xl" : "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-2xl border border-neutral-200/90 bg-white p-0 shadow-2xl shadow-neutral-950/20 dark:border-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
} }
> >
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题 + 返回结构库 */} {/* header:列表视图=标题+筛选+新增;详情视图=结构标题 + 返回结构库 */}
{!detail ? ( {!detail ? (
<> <>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-slate-200/80 bg-white px-6 py-5 dark:border-slate-800/80 dark:bg-slate-900"> <div className="flex shrink-0 items-start justify-between gap-4 border-b border-slate-200/80 bg-white px-6 py-5 dark:border-[#263b5c]/80 dark:bg-[#0f172a]">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-full bg-[#F5222D]" aria-hidden /> <div className="h-2.5 w-2.5 rounded-full bg-[#F5222D] dark:bg-[#4096ff]" aria-hidden />
<h2 className="truncate text-xl font-bold tracking-tight text-slate-900 dark:text-slate-100"> <h2 className="truncate text-xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
工作流配置 · 报告结构 工作流配置 · 报告结构
</h2> </h2>
@@ -331,7 +331,7 @@ export function WorkflowConfigDialog({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-10 w-10 shrink-0 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-[#CF1322] dark:hover:bg-slate-800 dark:hover:text-slate-200" className="h-10 w-10 shrink-0 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-[#CF1322] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
aria-label="关闭" aria-label="关闭"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
@@ -339,7 +339,7 @@ export function WorkflowConfigDialog({
</Button> </Button>
</div> </div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200/80 bg-white px-6 py-3.5 dark:border-slate-800/60 dark:bg-slate-900"> <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200/80 bg-white px-6 py-3.5 dark:border-[#263b5c]/70 dark:bg-[#0f172a]">
<div className="flex min-w-[280px] flex-1 flex-wrap items-center gap-2.5"> <div className="flex min-w-[280px] flex-1 flex-wrap items-center gap-2.5">
<div className="relative min-w-[200px] max-w-xs flex-1"> <div className="relative min-w-[200px] max-w-xs flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" /> <Search className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
@@ -347,14 +347,14 @@ export function WorkflowConfigDialog({
value={titleFilter} value={titleFilter}
onChange={(event) => setTitleFilter(event.target.value)} onChange={(event) => setTitleFilter(event.target.value)}
placeholder="按标题或检索词筛选..." 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 && ( {titleFilter && (
<button <button
type="button" type="button"
aria-label="清空筛选" aria-label="清空筛选"
onClick={() => setTitleFilter("")} onClick={() => setTitleFilter("")}
className="absolute top-1/2 right-2.5 -translate-y-1/2 text-slate-400 hover:text-[#CF1322] dark:hover:text-slate-200" className="absolute top-1/2 right-2.5 -translate-y-1/2 text-slate-400 hover:text-[#CF1322] dark:hover:text-[#9bc7ff]"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -366,7 +366,7 @@ export function WorkflowConfigDialog({
value={statusFilter} value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as ReportStructureStatusFilter)} onChange={(event) => setStatusFilter(event.target.value as ReportStructureStatusFilter)}
aria-label="启用状态筛选" 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"
> >
<option value="all">全部状态</option> <option value="all">全部状态</option>
<option value="enabled">已启用</option> <option value="enabled">已启用</option>
@@ -380,7 +380,7 @@ export function WorkflowConfigDialog({
value={modeFilter} value={modeFilter}
onChange={(event) => setModeFilter(event.target.value as "all" | ReportStructureMode)} onChange={(event) => setModeFilter(event.target.value as "all" | ReportStructureMode)}
aria-label="大纲类型筛选" 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"
> >
<option value="all">全部大纲</option> <option value="all">全部大纲</option>
<option value="adaptive">弹性大纲</option> <option value="adaptive">弹性大纲</option>
@@ -391,11 +391,11 @@ export function WorkflowConfigDialog({
</div> </div>
<div className="flex shrink-0 items-center gap-2.5"> <div className="flex shrink-0 items-center gap-2.5">
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"> <span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300">
{visible.length} 套 {visible.length} 套
</span> </span>
<Button <Button
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] sm:text-sm" className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
onClick={() => { onClick={() => {
onOpenChange(false); onOpenChange(false);
const base = location.pathname.replace(/\/+$/, ""); const base = location.pathname.replace(/\/+$/, "");
@@ -409,9 +409,9 @@ export function WorkflowConfigDialog({
</div> </div>
</> </>
) : ( ) : (
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-100 bg-white px-6 py-4 dark:border-slate-800 dark:bg-slate-900"> <div className="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-100 bg-white px-6 py-4 dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex min-w-0 items-center gap-3.5"> <div className="flex min-w-0 items-center gap-3.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-b from-[#b91c1c] to-[#991b1b] text-white shadow-xs shadow-rose-900/20"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-b from-[#b91c1c] to-[#991b1b] text-white shadow-xs shadow-rose-900/20 dark:from-[#2b8ffe] dark:to-[#0958d9] dark:shadow-blue-950/30">
<FileText className="h-5 w-5 text-white" strokeWidth={1.9} aria-hidden /> <FileText className="h-5 w-5 text-white" strokeWidth={1.9} aria-hidden />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -420,8 +420,8 @@ export function WorkflowConfigDialog({
{detail.title} {detail.title}
</h2> </h2>
{detail.id === appliedStructureId && ( {detail.id === appliedStructureId && (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-rose-200/70 bg-rose-50 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300"> <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-rose-200/70 bg-rose-50 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden /> <span className="h-1.5 w-1.5 rounded-full bg-rose-600 dark:bg-[#69b1ff]" aria-hidden />
当前使用 当前使用
</span> </span>
)} )}
@@ -440,7 +440,7 @@ export function WorkflowConfigDialog({
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3.5 py-1.5 text-xs font-medium text-neutral-700 shadow-2xs transition-all duration-150 hover:border-neutral-300 hover:bg-neutral-50 active:bg-neutral-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3.5 py-1.5 text-xs font-medium text-neutral-700 shadow-2xs transition-all duration-150 hover:border-neutral-300 hover:bg-neutral-50 active:bg-neutral-100 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:hover:bg-[#1c2d4a]"
onClick={handleBackToList} onClick={handleBackToList}
> >
返回结构库 返回结构库
@@ -448,7 +448,7 @@ export function WorkflowConfigDialog({
<button <button
type="button" type="button"
aria-label="关闭弹窗" aria-label="关闭弹窗"
className="rounded-lg p-2 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-slate-800 dark:hover:text-slate-200" className="rounded-lg p-2 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -460,7 +460,7 @@ export function WorkflowConfigDialog({
{/* body */} {/* body */}
{!detail ? ( {!detail ? (
<> <>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/60 dark:bg-slate-900/40"> <div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/60 dark:bg-[#0b1426]">
<div className="p-6"> <div className="p-6">
{listQuery.isLoading ? ( {listQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-slate-500 dark:text-slate-400"> <div className="flex items-center justify-center py-16 text-sm text-slate-500 dark:text-slate-400">
@@ -488,7 +488,7 @@ export function WorkflowConfigDialog({
setStatusFilter("all"); setStatusFilter("all");
setModeFilter("all"); setModeFilter("all");
}} }}
className="inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3.5 py-1.5 text-xs font-medium text-[#CF1322] transition-colors hover:bg-slate-200 dark:bg-rose-950/50 dark:text-[#FA9196]" className="inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3.5 py-1.5 text-xs font-medium text-[#CF1322] transition-colors hover:bg-slate-200 dark:bg-[#17345c] dark:text-[#9bc7ff] dark:hover:bg-[#1d467a]"
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
重置所有筛选 重置所有筛选
@@ -528,12 +528,12 @@ export function WorkflowConfigDialog({
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200/80 bg-white px-6 py-3.5 text-xs dark:border-slate-800 dark:bg-slate-900"> <div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200/80 bg-white px-6 py-3.5 text-xs dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex min-w-0 items-center gap-2 truncate text-slate-500 dark:text-slate-400"> <div className="flex min-w-0 items-center gap-2 truncate text-slate-500 dark:text-slate-400">
<span className="shrink-0">当前已选用:</span> <span className="shrink-0">当前已选用:</span>
{appliedStructureId ? ( {appliedStructureId ? (
<span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#FA9196]"> <span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#9bc7ff]">
<Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D]" /> <Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D] dark:text-[#4096ff]" />
<span className="truncate"> <span className="truncate">
{structures.find((item) => item.id === appliedStructureId)?.title ?? "当前结构"} {structures.find((item) => item.id === appliedStructureId)?.title ?? "当前结构"}
({reportStructureModeLabel(structures.find((item) => item.id === appliedStructureId)?.structureMode ?? "adaptive")}) ({reportStructureModeLabel(structures.find((item) => item.id === appliedStructureId)?.structureMode ?? "adaptive")})
@@ -546,7 +546,7 @@ export function WorkflowConfigDialog({
<Button <Button
variant="ghost" variant="ghost"
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
完成 完成
@@ -554,11 +554,11 @@ export function WorkflowConfigDialog({
</div> </div>
</> </>
) : ( ) : (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-slate-900"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-[#0b1426]">
<aside className="report-structure-detail-scrollbar flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-neutral-100 bg-[#fafbfc] p-[22px] md:w-[320px] md:border-r md:border-b-0 lg:w-[340px] dark:border-slate-800 dark:bg-slate-950/40"> <aside className="report-structure-detail-scrollbar flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-neutral-100 bg-[#fafbfc] p-[22px] md:w-[320px] md:border-r md:border-b-0 lg:w-[340px] dark:border-[#263b5c] dark:bg-[#0b1426]">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900"> <section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<Info className="h-3.5 w-3.5" strokeWidth={2} aria-hidden /> <Info className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div> </div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100"> <span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
@@ -570,9 +570,9 @@ export function WorkflowConfigDialog({
</p> </p>
</section> </section>
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900"> <section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<Compass className="h-3.5 w-3.5" strokeWidth={2} aria-hidden /> <Compass className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div> </div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100"> <span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
@@ -584,9 +584,9 @@ export function WorkflowConfigDialog({
{detail.retrievalDirections.map((direction) => ( {detail.retrievalDirections.map((direction) => (
<div <div
key={direction} key={direction}
className="flex items-center gap-1.5 rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300" className="flex items-center gap-1.5 rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
> >
<Tag className="h-3 w-3 shrink-0 text-rose-500" strokeWidth={2} aria-hidden /> <Tag className="h-3 w-3 shrink-0 text-rose-500 dark:text-[#69b1ff]" strokeWidth={2} aria-hidden />
<span className="leading-tight">{direction}</span> <span className="leading-tight">{direction}</span>
</div> </div>
))} ))}
@@ -598,17 +598,17 @@ export function WorkflowConfigDialog({
)} )}
</section> </section>
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900"> <section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<Database className="h-3.5 w-3.5" strokeWidth={2} aria-hidden /> <Database className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div> </div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100"> <span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
推荐检索来源 推荐检索来源
</span> </span>
</div> </div>
<span className="shrink-0 rounded-full border border-neutral-200/60 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"> <span className="shrink-0 rounded-full border border-neutral-200/60 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400">
{detail.retrievalSkills.length} 个渠道 {detail.retrievalSkills.length} 个渠道
</span> </span>
</div> </div>
@@ -617,7 +617,7 @@ export function WorkflowConfigDialog({
{detail.retrievalSkills.map((skill) => ( {detail.retrievalSkills.map((skill) => (
<span <span
key={skill} key={skill}
className="rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1 text-xs leading-tight text-neutral-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300" className="rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1 text-xs leading-tight text-neutral-600 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
> >
{skill} {skill}
</span> </span>
@@ -631,10 +631,10 @@ export function WorkflowConfigDialog({
</section> </section>
</aside> </aside>
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-slate-900"> <section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-[#0f172a]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-neutral-100 bg-white px-6 py-3.5 dark:border-slate-800 dark:bg-slate-900"> <div className="flex shrink-0 items-center justify-between gap-3 border-b border-neutral-100 bg-white px-6 py-3.5 dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<FileText className="h-3.5 w-3.5" strokeWidth={2} aria-hidden /> <FileText className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div> </div>
<span className="truncate text-sm font-semibold text-neutral-900 dark:text-slate-100"> <span className="truncate text-sm font-semibold text-neutral-900 dark:text-slate-100">
@@ -646,7 +646,7 @@ export function WorkflowConfigDialog({
<button <button
type="button" type="button"
onClick={() => void handleCopyOutline()} onClick={() => void handleCopyOutline()}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-2xs transition-all hover:bg-neutral-50 hover:text-neutral-900 active:scale-95 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100" className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-2xs transition-all hover:bg-neutral-50 hover:text-neutral-900 active:scale-95 dark:border-[#2a4164] dark:text-slate-300 dark:hover:border-[#2b8ffe]/70 dark:hover:bg-[#1c2d4a] dark:hover:text-slate-100"
title="复制大纲文本" title="复制大纲文本"
> >
{copiedOutlineId === detail.id ? ( {copiedOutlineId === detail.id ? (
@@ -661,15 +661,15 @@ export function WorkflowConfigDialog({
</> </>
)} )}
</button> </button>
<span className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200/80 bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 shadow-2xs dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300"> <span className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200/80 bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 shadow-2xs dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden /> <span className="h-1.5 w-1.5 rounded-full bg-rose-600 dark:bg-[#69b1ff]" aria-hidden />
{reportStructureModeLabel(detail.structureMode)} {reportStructureModeLabel(detail.structureMode)}
</span> </span>
</div> </div>
</div> </div>
<div className="report-structure-detail-scrollbar flex-1 space-y-6 overflow-y-auto bg-white p-6 md:p-8 dark:bg-slate-900"> <div className="report-structure-detail-scrollbar flex-1 space-y-6 overflow-y-auto bg-white p-6 md:p-8 dark:bg-[#0f172a]">
<div className="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-slate-800"> <div className="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-[#263b5c]">
<h3 className="min-w-0 truncate text-base font-bold tracking-tight text-neutral-900 dark:text-slate-100"> <h3 className="min-w-0 truncate text-base font-bold tracking-tight text-neutral-900 dark:text-slate-100">
{detailOutlineTitle} {detailOutlineTitle}
</h3> </h3>
@@ -784,10 +784,10 @@ function StructureCard({
}} }}
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${ className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
applied applied
? "border-[#F5222D] bg-gradient-to-b from-[#FEEDED]/90 via-[#FEFAFA] to-white shadow-sm ring-2 ring-[#F5222D]/20 dark:from-[#5C0011]/35 dark:via-slate-900 dark:to-slate-900" ? "border-[#F5222D] bg-gradient-to-b from-[#FEEDED]/90 via-[#FEFAFA] to-white shadow-sm ring-2 ring-[#F5222D]/20 dark:border-[#2b8ffe]/70 dark:from-[#0c3266]/70 dark:via-[#122347] dark:to-[#0e1c33] dark:ring-[#2b8ffe]/25"
: structure.enabled : structure.enabled
? "border-slate-200/90 bg-white hover:border-[#FA9196] hover:bg-gradient-to-b hover:from-[#FEFAFA]/80 hover:to-white hover:shadow-md dark:border-slate-800/90 dark:bg-slate-900 dark:hover:border-[#A8071A]/70" ? "border-slate-200/90 bg-white hover:border-[#FA9196] hover:bg-gradient-to-b hover:from-[#FEFAFA]/80 hover:to-white hover:shadow-md dark:border-[#263b5c]/90 dark:bg-[#111c31] dark:hover:border-[#2b8ffe]/70 dark:hover:bg-gradient-to-b dark:hover:from-[#152b4b] dark:hover:to-[#111c31]"
: "border-slate-200/60 bg-slate-50/80 opacity-75 hover:border-slate-300 hover:opacity-100 dark:border-slate-800/50 dark:bg-slate-900/40" : "border-slate-200/60 bg-slate-50/80 opacity-75 hover:border-slate-300 hover:opacity-100 dark:border-[#263b5c]/50 dark:bg-[#10192b]/70 dark:hover:border-[#38527a]"
}`} }`}
> >
<div> <div>
@@ -799,7 +799,7 @@ function StructureCard({
<div className="mt-2 flex flex-wrap items-center gap-1.5"> <div className="mt-2 flex flex-wrap items-center gap-1.5">
{applied && ( {applied && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#F5222D] px-2.5 py-0.5 text-xs font-semibold text-white shadow-xs"> <span className="inline-flex items-center gap-1 rounded-full bg-[#F5222D] px-2.5 py-0.5 text-xs font-semibold text-white shadow-xs dark:bg-[#1677ff]">
<Check className="h-3 w-3 stroke-[3]" /> <Check className="h-3 w-3 stroke-[3]" />
当前使用 当前使用
</span> </span>
@@ -807,18 +807,18 @@ function StructureCard({
<span <span
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${ className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${
structure.structureMode === "adaptive" structure.structureMode === "adaptive"
? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#820014] dark:bg-[#5C0011]/40 dark:text-[#FA9196]" ? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]"
: "border border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/40 dark:text-rose-300" : "border border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-[#38527a] dark:bg-[#15243d] dark:text-[#b8d4ff]"
}`} }`}
> >
<Layers className="h-3 w-3 text-[#F74750]" /> <Layers className="h-3 w-3 text-[#F74750] dark:text-[#69b1ff]" />
{reportStructureModeLabel(structure.structureMode)} {reportStructureModeLabel(structure.structureMode)}
</span> </span>
<span <span
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium ${ className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium ${
structure.enabled structure.enabled
? "border border-emerald-200/70 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300" ? "border border-emerald-200/70 bg-emerald-50 text-emerald-700 dark:border-emerald-700/55 dark:bg-emerald-950/35 dark:text-emerald-300"
: "border border-slate-200 bg-slate-100 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400" : "border border-slate-200 bg-slate-100 text-slate-500 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400"
}`} }`}
> >
{structure.enabled ? "已启用" : "已停用"} {structure.enabled ? "已启用" : "已停用"}
@@ -841,7 +841,7 @@ function StructureCard({
type="button" type="button"
title="查看大纲结构" title="查看大纲结构"
aria-label={`查看 ${structure.title} 的大纲结构`} aria-label={`查看 ${structure.title} 的大纲结构`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]" className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={onPreview} onClick={onPreview}
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
@@ -850,7 +850,7 @@ function StructureCard({
type="button" type="button"
title="复制为新结构" title="复制为新结构"
aria-label={`复制 ${structure.title}`} aria-label={`复制 ${structure.title}`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]" className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={onDuplicate} onClick={onDuplicate}
disabled={toggling} disabled={toggling}
> >
@@ -860,7 +860,7 @@ function StructureCard({
type="button" type="button"
title="编辑结构" title="编辑结构"
aria-label={`编辑 ${structure.title}`} aria-label={`编辑 ${structure.title}`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]" className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={onEdit} onClick={onEdit}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
@@ -884,7 +884,7 @@ function StructureCard({
{directions.length > 0 && ( {directions.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-slate-500 dark:text-slate-400"> <div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-slate-500 dark:text-slate-400">
<Compass className="h-3.5 w-3.5 text-[#F74750]" /> <Compass className="h-3.5 w-3.5 text-[#F74750] dark:text-[#69b1ff]" />
<span>检索方向</span> <span>检索方向</span>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -892,13 +892,13 @@ function StructureCard({
<span <span
key={direction} key={direction}
title={direction} title={direction}
className="inline-flex max-w-[200px] truncate rounded-md border border-[#FDDADC] bg-[#FEFAFA] px-2 py-0.5 text-xs text-slate-700 dark:border-slate-700/60 dark:bg-slate-800/90 dark:text-slate-300" className="inline-flex max-w-[200px] truncate rounded-md border border-[#FDDADC] bg-[#FEFAFA] px-2 py-0.5 text-xs text-slate-700 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
> >
{direction} {direction}
</span> </span>
))} ))}
{directions.length > 3 && ( {directions.length > 3 && (
<span className="inline-flex items-center rounded-md border border-[#FCB5B9] bg-[#FEEDED] px-1.5 py-0.5 text-[11px] font-medium text-[#F86C73] dark:border-slate-700/40 dark:bg-slate-800/70"> <span className="inline-flex items-center rounded-md border border-[#FCB5B9] bg-[#FEEDED] px-1.5 py-0.5 text-[11px] font-medium text-[#F86C73] dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-[#9bc7ff]">
+{directions.length - 3} +{directions.length - 3}
</span> </span>
)} )}
@@ -908,7 +908,7 @@ function StructureCard({
</div> </div>
<div <div
className="mt-auto flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-slate-800/80" className="mt-auto flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-[#263b5c]/80"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
role="presentation" role="presentation"
> >
@@ -934,8 +934,8 @@ function StructureCard({
disabled={toggling} disabled={toggling}
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`} aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
onClick={() => onToggleEnabled(!structure.enabled)} onClick={() => onToggleEnabled(!structure.enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60 ${ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 dark:focus-visible:ring-[#2b8ffe]/45 disabled:cursor-not-allowed disabled:opacity-60 ${
structure.enabled ? "bg-[#F5222D]" : "bg-slate-300 dark:bg-slate-700" structure.enabled ? "bg-[#F5222D] dark:bg-[#1677ff]" : "bg-slate-300 dark:bg-[#2a4164]"
}`} }`}
> >
<span <span
@@ -4,11 +4,12 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly. // @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts"; import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("includes the context compression toggle in the iframe chat toolbar", () => { test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: false, agentSelected: false,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true, memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
contextCompressionEnabled: false, contextCompressionEnabled: false,
@@ -19,7 +20,6 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
buttons.map((button) => button.key), buttons.map((button) => button.key),
[ [
"writing", "writing",
"memory",
"references", "references",
"contextCompression", "contextCompression",
"pro", "pro",
@@ -28,11 +28,43 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
); );
}); });
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
hideMemoryButton: false,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "memory"), true);
});
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false,
ragModeEnabled: true,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "references"), false);
});
test("returns only concrete toolbar button configs", () => { test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: true, agentSelected: true,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true, memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
contextCompressionEnabled: false, contextCompressionEnabled: false,
@@ -15,6 +15,9 @@ type BuildIframeModeButtonsInput = {
agentSelected: boolean; agentSelected: boolean;
writingModeEnabled: boolean; writingModeEnabled: boolean;
memoryInjectionEnabled: boolean; memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean; ragModeEnabled: boolean;
ragModeLocked: boolean; ragModeLocked: boolean;
contextCompressionEnabled: boolean; contextCompressionEnabled: boolean;
@@ -25,12 +28,14 @@ export function buildIframeModeButtons({
agentSelected, agentSelected,
writingModeEnabled, writingModeEnabled,
memoryInjectionEnabled, memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
contextCompressionEnabled, contextCompressionEnabled,
proModeEnabled, proModeEnabled,
}: BuildIframeModeButtonsInput): IframeModeButtonState[] { }: BuildIframeModeButtonsInput): IframeModeButtonState[] {
return [ const buttons: IframeModeButtonState[] = [
{ {
key: "writing", key: "writing",
label: "写作", label: "写作",
@@ -63,4 +68,9 @@ export function buildIframeModeButtons({
active: agentSelected, active: agentSelected,
}, },
]; ];
return buttons.filter(
(button) =>
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
);
} }
+15 -1
View File
@@ -373,7 +373,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` | | **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The default ordinary-Q&A Lead Agent prompt emits the matching Markdown heading forms and avoids unordered bullet lists; writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. | | **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The ordinary-Q&A Lead Agent can emit the matching Markdown heading forms and avoid unordered bullet lists when the admin enables `prompt_prefix.ordinary_qa_markdown_format_enabled` in `system_settings.json`; the switch defaults off, while writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. |
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. | | **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
| **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. | | **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing | | **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
@@ -526,6 +526,20 @@ Admin-configurable, **access-control-free** categories that published agents/ski
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries. Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
### Reference-mode Entry Switch
The 参考文献/RAG citation-mode composer entry is system-controlled through
`system_settings.json` → `citation_display.reference_mode_enabled` (default
`false`). `GET /api/system-settings/citation-display` remains readable so the
frontend can hide or show the entry consistently; `PUT` is admin-only and
partially updates `reference_mode_enabled` and/or `cleanup_words`. The normal
workspace input box, iframe chat toolbar, send context, right reference panel,
and appearance settings all use this flag, while already locked citation
threads and selected LLMWiki knowledge bases still render their source
documents in the reference panel. Tests:
`tests/test_prompt_prefix.py` and
`frontend-web/src/pages/iframe-chat-mode-buttons.test.ts`.
### Agent Module Tag Config (zzJC / zzXD 按类型聚合) ### Agent Module Tag Config (zzJC / zzXD 按类型聚合)
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header). Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
@@ -741,6 +741,29 @@ login and theme query parameters, each shortcut may include ordered keyless
it as an encoded path segment (including inside a `#/…` hash route); password it as an encoded path segment (including inside a `#/…` hash route); password
remains an ordinary login query parameter with the key `password`. remains an ordinary login query parameter with the key `password`.
### Citation display settings
Reference-mode entry points are controlled by `citation_display` in
`.deer-flow/system_settings.json`. `reference_mode_enabled` defaults to `false`;
when disabled, the frontend hides the "参考文献" composer button in both the
normal workspace and iframe chat, and hides the per-user RAG citation toggle
from appearance settings. Conversations with selected LLMWiki knowledge bases
still show their source documents in the reference panel even when the manual
entry is hidden. `GET /api/system-settings/citation-display` is
readable by logged-in users so the UI can decide whether to expose the entry;
`PUT /api/system-settings/citation-display` is admin-only and supports partial
updates for both `reference_mode_enabled` and `cleanup_words`.
### Ordinary Q&A Markdown format switch
Administrators can opt into the formal long-answer Markdown prompt from the
admin "问答提示词配置" panel. The value is persisted as
`prompt_prefix.ordinary_qa_markdown_format_enabled` in
`.deer-flow/system_settings.json` and defaults to `false`. When enabled, only
ordinary Q&A receives the `# 主标题` / `## 一、标题` / `### (一) 标题` /
`#### 1. 标题` rules; writing mode, notebook mode and scheduled runs remain
unaffected. The change takes effect when the next prompt is built.
--- ---
## Technology Stack ## Technology Stack
@@ -42,12 +42,17 @@ class PromptPrefixResponse(BaseModel):
enabled: bool = Field(..., description="Whether the toggle is exposed to users") enabled: bool = Field(..., description="Whether the toggle is exposed to users")
switch_label: str = Field(..., description="Toggle label shown in the chat input") switch_label: str = Field(..., description="Toggle label shown in the chat input")
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message") prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
ordinary_qa_markdown_format_enabled: bool = Field(
default=False,
description="Whether formal Markdown heading rules are injected into ordinary Q&A system prompts",
)
class PromptPrefixUpdateRequest(BaseModel): class PromptPrefixUpdateRequest(BaseModel):
enabled: bool | None = None enabled: bool | None = None
switch_label: str | None = None switch_label: str | None = None
prompt_prefix: str | None = None prompt_prefix: str | None = None
ordinary_qa_markdown_format_enabled: bool | None = None
class PluginDomainAgentMappingItem(BaseModel): class PluginDomainAgentMappingItem(BaseModel):
@@ -82,10 +87,12 @@ class PrivateSquareUpdateRequest(BaseModel):
class CitationDisplayResponse(BaseModel): class CitationDisplayResponse(BaseModel):
reference_mode_enabled: bool = Field(default=False, description="Whether reference-mode entry points are exposed to users")
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words") cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
class CitationDisplayUpdateRequest(BaseModel): class CitationDisplayUpdateRequest(BaseModel):
reference_mode_enabled: bool | None = None
cleanup_words: list[str] | None = None cleanup_words: list[str] | None = None
@@ -110,6 +117,7 @@ def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
enabled=settings.enabled, enabled=settings.enabled,
switch_label=settings.switch_label, switch_label=settings.switch_label,
prompt_prefix=settings.prompt_prefix, prompt_prefix=settings.prompt_prefix,
ordinary_qa_markdown_format_enabled=settings.ordinary_qa_markdown_format_enabled,
) )
@@ -410,9 +418,12 @@ async def update_leaderboard_settings(
@router.get("/citation-display", response_model=CitationDisplayResponse) @router.get("/citation-display", response_model=CitationDisplayResponse)
async def get_citation_display() -> CitationDisplayResponse: async def get_citation_display() -> CitationDisplayResponse:
"""Return global citation display cleanup settings.""" """Return global citation display settings."""
settings = load_system_settings().citation_display settings = load_system_settings().citation_display
return CitationDisplayResponse(cleanup_words=settings.cleanup_words) return CitationDisplayResponse(
reference_mode_enabled=settings.reference_mode_enabled,
cleanup_words=settings.cleanup_words,
)
@router.put("/citation-display", response_model=CitationDisplayResponse) @router.put("/citation-display", response_model=CitationDisplayResponse)
@@ -420,14 +431,22 @@ async def update_citation_display(
body: CitationDisplayUpdateRequest, body: CitationDisplayUpdateRequest,
request: Request, request: Request,
) -> CitationDisplayResponse: ) -> CitationDisplayResponse:
"""Update global citation display cleanup settings. Admin only.""" """Update global citation display settings. Admin only."""
await _require_admin(request) await _require_admin(request)
current = load_system_settings() current = load_system_settings()
words = [str(item).strip() for item in (body.cleanup_words or []) if str(item).strip()] fields = body.model_dump(exclude_unset=True)
updated = current.citation_display.model_copy(update={"cleanup_words": words}) update: dict[str, object] = {}
if "reference_mode_enabled" in fields and fields["reference_mode_enabled"] is not None:
update["reference_mode_enabled"] = bool(fields["reference_mode_enabled"])
if "cleanup_words" in fields and fields["cleanup_words"] is not None:
update["cleanup_words"] = [str(item).strip() for item in fields["cleanup_words"] if str(item).strip()]
updated = current.citation_display.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"citation_display": updated})) saved = save_system_settings(current.model_copy(update={"citation_display": updated}))
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words) return CitationDisplayResponse(
reference_mode_enabled=saved.citation_display.reference_mode_enabled,
cleanup_words=saved.citation_display.cleanup_words,
)
@router.get("/private-square", response_model=PrivateSquareStatusResponse) @router.get("/private-square", response_model=PrivateSquareStatusResponse)
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from deerflow.config.agents_config import load_agent_soul from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names from deerflow.subagents import get_available_subagent_names
@@ -498,6 +499,15 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
""" """
def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch."""
try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception:
logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True)
return False
SYSTEM_PROMPT_TEMPLATE = """ SYSTEM_PROMPT_TEMPLATE = """
<role> <role>
You are {agent_name}, an internal super agent. You are {agent_name}, an internal super agent.
@@ -1539,7 +1549,11 @@ When a search, knowledge, skill, or MCP tool returns a result list:
) )
ordinary_qa_format_section = ( ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION _ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if not is_bootstrap and not writing_mode and not is_notebook_mode and not is_scheduled_run if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap
and not writing_mode
and not is_notebook_mode
and not is_scheduled_run
else "" else ""
) )
@@ -153,6 +153,10 @@ class PromptPrefixSettings(BaseModel):
enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all") enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all")
switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input") switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input")
prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled") prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled")
ordinary_qa_markdown_format_enabled: bool = Field(
default=False,
description="Whether the formal Markdown heading rules are injected into ordinary Q&A system prompts.",
)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -255,8 +259,12 @@ class AppearanceSettings(BaseModel):
class CitationDisplaySettings(BaseModel): class CitationDisplaySettings(BaseModel):
"""System-wide cleanup rules for citation cards and detail dialogs.""" """System-wide controls for citation mode entry and display cleanup."""
reference_mode_enabled: bool = Field(
default=False,
description="Whether the reference-mode entry points are exposed to users.",
)
cleanup_words: list[str] = Field( cleanup_words: list[str] = Field(
default_factory=list, default_factory=list,
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.", description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
@@ -112,6 +112,7 @@ def test_es_query_routing_registration_log_explains_custom_agent_exclusion(caplo
def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None: def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None:
config = _app_config(enabled=False) config = _app_config(enabled=False)
monkeypatch.setattr(prompt_module, "_ordinary_qa_markdown_format_enabled", lambda: True)
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "") monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "") monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "") monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
@@ -134,3 +135,19 @@ def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None:
assert '<ordinary_qa_markdown_format>' not in writing_prompt assert '<ordinary_qa_markdown_format>' not in writing_prompt
assert '<ordinary_qa_markdown_format>' not in notebook_prompt assert '<ordinary_qa_markdown_format>' not in notebook_prompt
assert '<ordinary_qa_markdown_format>' not in scheduled_prompt assert '<ordinary_qa_markdown_format>' not in scheduled_prompt
def test_formal_markdown_rules_are_disabled_when_switch_is_off(monkeypatch) -> None:
config = _app_config(enabled=False)
monkeypatch.setattr(prompt_module, "_ordinary_qa_markdown_format_enabled", lambda: False)
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_current_time_context", lambda: "")
ordinary_prompt = prompt_module.apply_prompt_template(app_config=config)
assert '<ordinary_qa_markdown_format>' not in ordinary_prompt
@@ -9,7 +9,9 @@ from types import SimpleNamespace
import pytest import pytest
from langchain_core.messages import AIMessage, HumanMessage from langchain_core.messages import AIMessage, HumanMessage
from app.gateway.routers import system_settings as ss_router
from app.gateway.services import _prepend_prompt_prefix, _prompt_prefix_opted_in from app.gateway.services import _prepend_prompt_prefix, _prompt_prefix_opted_in
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config import system_settings as ss from deerflow.config import system_settings as ss
@@ -28,6 +30,9 @@ def test_load_returns_defaults_when_missing(tmp_runtime_home: Path) -> None:
settings = ss.load_system_settings() settings = ss.load_system_settings()
assert settings.prompt_prefix.enabled is False assert settings.prompt_prefix.enabled is False
assert settings.prompt_prefix.prompt_prefix == "" assert settings.prompt_prefix.prompt_prefix == ""
assert settings.prompt_prefix.ordinary_qa_markdown_format_enabled is False
assert settings.citation_display.reference_mode_enabled is False
assert settings.citation_display.cleanup_words == []
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None: def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
@@ -36,6 +41,7 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
enabled=True, enabled=True,
switch_label="加入立场前缀", switch_label="加入立场前缀",
prompt_prefix="你需要站在中方角度", prompt_prefix="你需要站在中方角度",
ordinary_qa_markdown_format_enabled=True,
) )
) )
ss.save_system_settings(settings) ss.save_system_settings(settings)
@@ -49,6 +55,88 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
assert reloaded.prompt_prefix.enabled is True assert reloaded.prompt_prefix.enabled is True
assert reloaded.prompt_prefix.switch_label == "加入立场前缀" assert reloaded.prompt_prefix.switch_label == "加入立场前缀"
assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度" assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度"
assert reloaded.prompt_prefix.ordinary_qa_markdown_format_enabled is True
def test_runtime_markdown_format_switch_defaults_off_and_updates_without_restart(
tmp_runtime_home: Path,
) -> None:
assert prompt_module._ordinary_qa_markdown_format_enabled() is False
ss.save_system_settings(
ss.SystemSettings(
prompt_prefix=ss.PromptPrefixSettings(
ordinary_qa_markdown_format_enabled=True,
)
)
)
assert prompt_module._ordinary_qa_markdown_format_enabled() is True
def _patch_admin(monkeypatch: pytest.MonkeyPatch) -> None:
admin = SimpleNamespace(
id="admin1",
system_role="admin",
email="admin@example.com",
)
async def _optional(_request):
return admin
monkeypatch.setattr(ss_router, "get_optional_user_from_request", _optional)
@pytest.mark.asyncio
async def test_prompt_settings_update_can_enable_markdown_format_without_changing_prefix(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
current = ss.SystemSettings(
prompt_prefix=ss.PromptPrefixSettings(
enabled=True,
switch_label="加入立场前缀",
prompt_prefix="你需要站在中方角度",
)
)
ss.save_system_settings(current)
response = await ss_router.update_prompt_prefix(
ss_router.PromptPrefixUpdateRequest(ordinary_qa_markdown_format_enabled=True),
SimpleNamespace(),
)
assert response.ordinary_qa_markdown_format_enabled is True
assert response.enabled is True
assert response.switch_label == "加入立场前缀"
assert response.prompt_prefix == "你需要站在中方角度"
@pytest.mark.asyncio
async def test_citation_display_update_preserves_unspecified_fields(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
current = ss.SystemSettings(
citation_display=ss.CitationDisplaySettings(
cleanup_words=["广告", "免责声明"],
)
)
ss.save_system_settings(current)
response = await ss_router.update_citation_display(
ss_router.CitationDisplayUpdateRequest(reference_mode_enabled=True),
SimpleNamespace(),
)
assert response.reference_mode_enabled is True
assert response.cleanup_words == ["广告", "免责声明"]
payload = json.loads((tmp_runtime_home / "system_settings.json").read_text(encoding="utf-8"))
assert payload["citation_display"]["reference_mode_enabled"] is True
assert payload["citation_display"]["cleanup_words"] == ["广告", "免责声明"]
def test_prepend_prefix_tags_first_human_without_touching_content() -> None: def test_prepend_prefix_tags_first_human_without_touching_content() -> None: