Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
This commit is contained in:
commit
553f72604a
33 files changed
+1033
-184
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,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
Fragment,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
type PropsWithChildren,
|
||||
@@ -124,8 +123,10 @@ export const usePromptInputController = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
||||
const useOptionalPromptInputController = () =>
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
||||
// Fast Refresh transitions where a page can briefly retain the previous
|
||||
// provider context instance.
|
||||
export const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
|
||||
export const useProviderAttachments = () => {
|
||||
@@ -406,9 +407,9 @@ export function PromptInputAttachments({
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<Fragment key={file.id}>
|
||||
<div className="max-w-60">{children(file)}</div>
|
||||
</Fragment>
|
||||
<div key={file.id} className="max-w-60">
|
||||
{children(file)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-f
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -87,7 +88,11 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { isMock, thread } = useThread();
|
||||
const {
|
||||
isMock,
|
||||
thread,
|
||||
knowledgeReferencesEnabled = false,
|
||||
} = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
@@ -106,8 +111,13 @@ const ChatBox: React.FC<{
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
// the existing reference panel, but must not request or write reference
|
||||
// batches through DeerFlow's backend APIs.
|
||||
@@ -121,7 +131,7 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasFrontendRag;
|
||||
const shouldFetchBackendReferenceBatches =
|
||||
referencesEnabled && !hasFrontendRag;
|
||||
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,11 @@ export function useSpecificChatMode() {
|
||||
const { t } = useI18n();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const promptInputController = usePromptInputController();
|
||||
// A regular history item can navigate here while Vite Fast Refresh is
|
||||
// reconciling the previous page tree. In that short window the page may see
|
||||
// a stale provider context. This hook only pre-fills the skill-mode draft,
|
||||
// so it must not make an ordinary chat page fail to render.
|
||||
const promptInputController = useOptionalPromptInputController();
|
||||
const inputInitialValue = useMemo(() => {
|
||||
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
||||
return undefined;
|
||||
@@ -19,8 +23,10 @@ export function useSpecificChatMode() {
|
||||
return t.inputBox.createSkillPrompt;
|
||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
||||
setInputRef.current = promptInputController.textInput.setInput;
|
||||
const setInputRef = useRef<((value: string) => void) | undefined>(
|
||||
promptInputController?.textInput.setInput,
|
||||
);
|
||||
setInputRef.current = promptInputController?.textInput.setInput;
|
||||
useEffect(() => {
|
||||
if (
|
||||
inputInitialValue &&
|
||||
@@ -28,7 +34,10 @@ export function useSpecificChatMode() {
|
||||
) {
|
||||
lastInitialValueRef.current = inputInitialValue;
|
||||
setTimeout(() => {
|
||||
setInputRef.current(inputInitialValue);
|
||||
const setInput = setInputRef.current;
|
||||
if (!setInput) return;
|
||||
|
||||
setInput(inputInitialValue);
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||||
*
|
||||
* 三块结构(自上而下):
|
||||
* 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
|
||||
* 决定。三块结构(自上而下):
|
||||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||||
@@ -85,6 +86,7 @@ import {
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
@@ -93,11 +95,6 @@ import { cn } from "@/lib/utils";
|
||||
import { agentColor } from "./agent-icon-color";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
/** 「更多」常用智能体跳转目标:智能体管理页。 */
|
||||
const MORE_AGENTS_HREF = "/page/workspace/agents";
|
||||
/** 「更多最近对话」跳转目标。 */
|
||||
const MORE_CHATS_HREF = "/page/workspace/chats";
|
||||
/** 常用智能体最多展示个数。 */
|
||||
const MAX_FREQUENT = 4;
|
||||
|
||||
@@ -142,6 +139,7 @@ function SectionLabel({
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
const { t } = useI18n();
|
||||
const routeBase = useRouteBase();
|
||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -188,9 +186,14 @@ export function GeneralQaSidebar() {
|
||||
const searchParams = useSearchParams();
|
||||
const activeAgentId = searchParams.get("agent");
|
||||
const currentSearch = searchParams.toString();
|
||||
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
|
||||
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
|
||||
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
|
||||
const chatsBasePath = `${routeBase}/chats`;
|
||||
const newChatPath = `${chatsBasePath}/new`;
|
||||
const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
|
||||
const moreAgentsHref = buildFullChatNavigationPath(
|
||||
`${routeBase}/agents`,
|
||||
currentSearch,
|
||||
);
|
||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||
@@ -199,19 +202,18 @@ export function GeneralQaSidebar() {
|
||||
(agentId: string) => {
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
|
||||
routeBase === "/page/workspace"
|
||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[currentSearch, navigate],
|
||||
[currentSearch, navigate, newChatPath, routeBase],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
||||
thread_id: string;
|
||||
agent_id?: string;
|
||||
}>();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
@@ -224,6 +226,14 @@ export function GeneralQaSidebar() {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
const pathForThread = useCallback(
|
||||
(thread: AgentThread) =>
|
||||
isBootstrapThread(thread)
|
||||
? listPathOfThread(thread)
|
||||
: pathOfThread(thread, undefined, routeBase),
|
||||
[routeBase],
|
||||
);
|
||||
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||||
@@ -254,21 +264,22 @@ export function GeneralQaSidebar() {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
let nextThreadPath = newChatPath;
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = pathForThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = pathForThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
||||
}
|
||||
},
|
||||
[
|
||||
agentIdFromPath,
|
||||
currentSearch,
|
||||
deleteThread,
|
||||
newChatPath,
|
||||
pathForThread,
|
||||
router,
|
||||
threadIdFromPath,
|
||||
threads,
|
||||
@@ -467,7 +478,7 @@ export function GeneralQaSidebar() {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const href = buildFullChatNavigationPath(
|
||||
listPathOfThread(thread),
|
||||
pathForThread(thread),
|
||||
currentSearch,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
|
||||
@@ -98,7 +98,7 @@ import {
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
@@ -143,6 +143,7 @@ export function InputBox({
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
hideMemoryTool = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -153,6 +154,11 @@ export function InputBox({
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
/**
|
||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||
* 写作和参考文献开关不受影响。
|
||||
*/
|
||||
hideMemoryTool?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
@@ -224,6 +230,9 @@ export function InputBox({
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -804,7 +813,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
@@ -830,7 +839,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
|
||||
@@ -7,6 +7,12 @@ import type { AgentThreadState } from "@/core/threads";
|
||||
export interface ThreadContextType {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
/**
|
||||
* True when the active conversation has selected LLMWiki knowledge bases.
|
||||
* Knowledge-base Q&A should always expose its source documents, independent
|
||||
* from the admin-controlled manual reference-mode button.
|
||||
*/
|
||||
knowledgeReferencesEnabled?: boolean;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
* Artifact/image URLs then resolve to the unauthenticated
|
||||
|
||||
@@ -88,11 +88,13 @@ export function MessageGroup({
|
||||
messages,
|
||||
isLoading = false,
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
@@ -111,8 +113,8 @@ export function MessageGroup({
|
||||
);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
@@ -207,7 +209,7 @@ export function MessageGroup({
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -219,7 +221,7 @@ export function MessageGroup({
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
@@ -301,8 +302,15 @@ export function MessageList({
|
||||
}, [messages]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() =>
|
||||
messages.some(
|
||||
@@ -783,6 +791,7 @@ export function MessageList({
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -835,6 +844,7 @@ export function MessageList({
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
|
||||
@@ -205,6 +205,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
setShortcutDraft(
|
||||
@@ -845,6 +847,27 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<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">
|
||||
管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={referenceModeEntryEnabled}
|
||||
disabled={updateCitationDisplay.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
updateCitationDisplay.mutate({
|
||||
reference_mode_enabled: checked,
|
||||
})
|
||||
}
|
||||
aria-label="参考文献模式入口"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{referenceModeEntryEnabled && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
@@ -860,6 +883,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -5,11 +5,13 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
|
||||
export function ThreadTitle({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
@@ -49,7 +51,7 @@ export function ThreadTitle({
|
||||
}
|
||||
const visibleTitle = desensitize(displayTitle);
|
||||
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}>
|
||||
{visibleTitle}
|
||||
</span>
|
||||
|
||||
@@ -34,7 +34,9 @@ export function WorkspaceSidebar({
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
const isGeneralQa =
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/strategy/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
|
||||
@@ -73,12 +73,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
// icon: Briefcase,
|
||||
// path: "/page/strategy/qa/position-roundtable",
|
||||
// },
|
||||
{
|
||||
id: "dashboard-agent",
|
||||
label: "大屏绘制",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/dashboard-agent",
|
||||
},
|
||||
// {
|
||||
// id: "dashboard-agent",
|
||||
// label: "大屏绘制",
|
||||
// icon: Gauge,
|
||||
// path: "/page/strategy/dashboard-agent",
|
||||
// },
|
||||
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{
|
||||
id: "qa-zzjc",
|
||||
@@ -88,12 +88,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
{
|
||||
id: "qa-research-chat",
|
||||
label: "课题研究(对话版)",
|
||||
icon: FileText,
|
||||
path: "/page/canvas/ai-writing-chat",
|
||||
},
|
||||
// {
|
||||
// id: "qa-research-chat",
|
||||
// label: "课题研究(对话版)",
|
||||
// icon: FileText,
|
||||
// path: "/page/canvas/ai-writing-chat",
|
||||
// },
|
||||
{
|
||||
id: "qa-deep-research",
|
||||
label: "深度研究",
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface PromptPrefixSettings {
|
||||
enabled: boolean;
|
||||
switch_label: string;
|
||||
prompt_prefix: string;
|
||||
ordinary_qa_markdown_format_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PluginDomainAgentMappingDto {
|
||||
@@ -37,6 +38,7 @@ export interface PromptPrefixUpdate {
|
||||
enabled?: boolean;
|
||||
switch_label?: string;
|
||||
prompt_prefix?: string;
|
||||
ordinary_qa_markdown_format_enabled?: boolean;
|
||||
}
|
||||
|
||||
function fromPluginManagementDto(data: {
|
||||
@@ -173,10 +175,12 @@ export interface PrivateSquareUpdate {
|
||||
}
|
||||
|
||||
export interface CitationDisplaySettings {
|
||||
reference_mode_enabled: boolean;
|
||||
cleanup_words: string[];
|
||||
}
|
||||
|
||||
export interface CitationDisplayUpdate {
|
||||
reference_mode_enabled?: boolean;
|
||||
cleanup_words?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ import { useI18n } from "../i18n/hooks";
|
||||
import { OptType, writeYLog } from "../log/y-log";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "../settings";
|
||||
import { useCitationDisplaySettings } from "../system-settings/hooks";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -568,6 +573,9 @@ export function useThreadStream({
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -1505,7 +1513,7 @@ export function useThreadStream({
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled ||
|
||||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0;
|
||||
if (ragModeEnabled) {
|
||||
@@ -1625,7 +1633,17 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin],
|
||||
[
|
||||
thread,
|
||||
_handleOnStart,
|
||||
t.uploads.uploadingFiles,
|
||||
context,
|
||||
referenceModeEntryEnabled,
|
||||
localSettings.display.ragModeEnabled,
|
||||
queryClient,
|
||||
runOptions,
|
||||
confirmOrRejoin,
|
||||
],
|
||||
);
|
||||
|
||||
const handleThreadStop = useCallback(async () => {
|
||||
|
||||
@@ -27,6 +27,18 @@ function pickAgentRef(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the agent that owns a thread when that ownership was persisted on
|
||||
* either its run context or its metadata. Keeping this in one place ensures
|
||||
* sidebars and route recovery make the same decision for older deep links.
|
||||
*/
|
||||
export function agentRefOfThread(thread: {
|
||||
context?: ThreadAgentRef | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): string | undefined {
|
||||
return pickAgentRef(thread.context, thread.metadata);
|
||||
}
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: ThreadAgentRef | null,
|
||||
@@ -36,7 +48,7 @@ export function pathOfThread(
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: pickAgentRef(thread.context, thread.metadata);
|
||||
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
|
||||
|
||||
return agentRef
|
||||
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {
|
||||
BotIcon,
|
||||
Compass,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PanelRightOpenIcon,
|
||||
PlusSquare,
|
||||
} from "lucide-react";
|
||||
@@ -85,6 +87,7 @@ import {
|
||||
AGENTFX_REPORT_JSON_OUTPUT_PATH,
|
||||
notifyTaskReportSaved,
|
||||
} from "@/strategy-components/lib/agentfx-report-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
||||
@@ -225,6 +228,9 @@ export default function AgentChatPage({
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const router = useRouter();
|
||||
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
|
||||
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
|
||||
const [agentSidebarOpen, setAgentSidebarOpen] = useState(true);
|
||||
|
||||
const { agent_id: agentIdFromPath } = useParams<{
|
||||
agent_id: string;
|
||||
@@ -777,7 +783,13 @@ export default function AgentChatPage({
|
||||
: "ready";
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread: displayThread }}>
|
||||
<ThreadContext.Provider
|
||||
value={{
|
||||
thread: displayThread,
|
||||
knowledgeReferencesEnabled:
|
||||
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
|
||||
}}
|
||||
>
|
||||
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
|
||||
preview) lives in the left flex cell; the agent detail panel is a
|
||||
separate flex sibling OUTSIDE ChatBox, so the artifact preview
|
||||
@@ -788,7 +800,11 @@ export default function AgentChatPage({
|
||||
{showAgentList && (
|
||||
<div
|
||||
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} />
|
||||
</div>
|
||||
@@ -800,6 +816,25 @@ export default function AgentChatPage({
|
||||
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
|
||||
{/* 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">
|
||||
{/* 智能体列表栏的本地收展入口。 */}
|
||||
{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">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
@@ -875,23 +910,6 @@ export default function AgentChatPage({
|
||||
开始回答
|
||||
</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+),
|
||||
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
|
||||
@@ -913,6 +931,23 @@ export default function AgentChatPage({
|
||||
enabled={tokenUsageEnabled}
|
||||
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} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
|
||||
@@ -38,12 +38,16 @@ import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import {
|
||||
buildFullChatNavigationPath,
|
||||
isChatsIframeWorkspacePath,
|
||||
} from "@/core/page-layout/chats-iframe-route";
|
||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { MessageSquarePlus, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
@@ -114,6 +118,7 @@ function clearBrowserContextParamsFromHash() {
|
||||
export default function ChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// `chatMode=full` is used by an embedded standard chat. It intentionally
|
||||
@@ -671,6 +676,20 @@ export default function ChatPage() {
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
const compactActionsHidden = isHideChromeActive();
|
||||
const headerNewChatPath = useMemo(
|
||||
() =>
|
||||
buildFullChatNavigationPath(
|
||||
`${routeBase}/chats/new`,
|
||||
searchParams.toString(),
|
||||
),
|
||||
[routeBase, searchParams],
|
||||
);
|
||||
const handleHeaderNewChat = useCallback(() => {
|
||||
navigate(headerNewChatPath, {
|
||||
replace: true,
|
||||
state: { resetAt: Date.now() },
|
||||
});
|
||||
}, [headerNewChatPath, navigate]);
|
||||
// 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
|
||||
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
|
||||
// 全局 chrome,但不该在外部系统里弹引导。
|
||||
@@ -684,7 +703,14 @@ export default function ChatPage() {
|
||||
!isWujieEnv();
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
|
||||
<ThreadContext.Provider
|
||||
value={{
|
||||
thread: displayThread,
|
||||
isMock,
|
||||
knowledgeReferencesEnabled:
|
||||
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
|
||||
}}
|
||||
>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div
|
||||
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">
|
||||
<ThreadTitle threadId={threadId} thread={displayThread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
|
||||
{/* 左上角始终提供左栏切换;展开态显示“收起”,收起态显示“展开”。 */}
|
||||
{compactChrome && !compactActionsHidden && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleHistorySidebar}
|
||||
title="打开历史记录"
|
||||
aria-label="打开历史记录"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
|
||||
aria-label={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
|
||||
className="mr-1 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{historySidebarOpen ? (
|
||||
<PanelLeftClose className="size-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<ThreadTitle
|
||||
className="-translate-y-1"
|
||||
threadId={threadId}
|
||||
thread={displayThread}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
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} />
|
||||
<KnowledgeSaveTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
@@ -790,9 +836,8 @@ export default function ChatPage() {
|
||||
isLandingState &&
|
||||
!compactLanding &&
|
||||
"-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-(--container-width-lg)",
|
||||
)}
|
||||
|
||||
@@ -39,8 +39,14 @@ import {
|
||||
useLocalSettings,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||
import {
|
||||
useCitationDisplaySettings,
|
||||
usePluginManagementSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
stopActiveThreadBeforeClarificationReply,
|
||||
useThreadStream,
|
||||
} from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
import { env } from "@/env";
|
||||
@@ -379,6 +385,9 @@ export default function IframeChatPage() {
|
||||
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const contextCompressionEnabled =
|
||||
@@ -525,6 +534,7 @@ export default function IframeChatPage() {
|
||||
memoryInjectionEnabled: Boolean(
|
||||
settings.context.memory_injection_enabled ?? true,
|
||||
),
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
|
||||
@@ -46,6 +46,8 @@ export default function PromptPrefixSettingsPage({
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [switchLabel, setSwitchLabel] = useState("");
|
||||
const [promptPrefix, setPromptPrefix] = useState("");
|
||||
const [ordinaryQaMarkdownFormatEnabled, setOrdinaryQaMarkdownFormatEnabled] =
|
||||
useState(false);
|
||||
// Only seed the draft from server data once. Re-syncing every time react-query
|
||||
// refetches (window focus, route remount, etc.) would silently overwrite the
|
||||
// user's in-progress edits — and made the toggle look unresponsive: a click
|
||||
@@ -57,6 +59,9 @@ export default function PromptPrefixSettingsPage({
|
||||
setEnabled(data.enabled);
|
||||
setSwitchLabel(data.switch_label);
|
||||
setPromptPrefix(data.prompt_prefix);
|
||||
setOrdinaryQaMarkdownFormatEnabled(
|
||||
data.ordinary_qa_markdown_format_enabled === true,
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
@@ -79,6 +84,8 @@ export default function PromptPrefixSettingsPage({
|
||||
enabled,
|
||||
switch_label: switchLabel,
|
||||
prompt_prefix: promptPrefix,
|
||||
ordinary_qa_markdown_format_enabled:
|
||||
ordinaryQaMarkdownFormatEnabled,
|
||||
});
|
||||
toast.success("已保存",darkToastOpts.success);
|
||||
} catch (e) {
|
||||
@@ -95,10 +102,9 @@ export default function PromptPrefixSettingsPage({
|
||||
}
|
||||
>
|
||||
<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">
|
||||
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
|
||||
会将下方配置的前缀文本和原问题拼接后再发送给模型。
|
||||
管理员可在此配置问答前缀和普通问答的固定 Markdown 格式提示词。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -147,6 +153,21 @@ export default function PromptPrefixSettingsPage({
|
||||
/>
|
||||
</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">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
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 { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
@@ -79,6 +80,24 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
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() {
|
||||
const storedAuth = getStoredAuth();
|
||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||
@@ -342,11 +361,7 @@ export default function StrategyRoutes() {
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
element={<StrategyChatThreadLayout />}
|
||||
/>
|
||||
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
|
||||
@@ -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 { getStoredAuth } from '@/core/auth';
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
|
||||
import {
|
||||
resolveChatsIframeEntryRedirect,
|
||||
@@ -25,6 +26,7 @@ import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
|
||||
|
||||
import { RouteBaseProvider } from '@/core/route-base';
|
||||
import { agentRefOfThread } from '@/core/threads/utils';
|
||||
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
@@ -180,7 +182,11 @@ export function WorkspaceLayout({
|
||||
'--header-height': headerHidden ? '0rem' : '4rem',
|
||||
} 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}
|
||||
defaultOpen={
|
||||
forceWorkspaceSidebarCollapsed
|
||||
@@ -341,12 +347,80 @@ function ChatThreadEntryRoute({
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
<AgentAwareChatThreadContent />
|
||||
</ChatRuntime>
|
||||
</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`
|
||||
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
||||
|
||||
@@ -155,12 +155,12 @@ export function ResearchSandboxPanel({
|
||||
/>
|
||||
<ResizablePanel
|
||||
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",
|
||||
)}
|
||||
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 ? (
|
||||
<>
|
||||
{documentRewriteActive && onCancelDocumentRewrite && (
|
||||
|
||||
@@ -114,7 +114,7 @@ const outlineMarkdownClassName =
|
||||
"[&_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 " +
|
||||
"[&_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 " +
|
||||
"[&_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";
|
||||
@@ -309,17 +309,17 @@ export function WorkflowConfigDialog({
|
||||
overlayClassName="bg-slate-900/60 backdrop-blur-xs"
|
||||
className={
|
||||
!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-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-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-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
|
||||
}
|
||||
>
|
||||
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题 + 返回结构库 */}
|
||||
{!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="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>
|
||||
@@ -331,7 +331,7 @@ export function WorkflowConfigDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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="关闭"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
@@ -339,7 +339,7 @@ export function WorkflowConfigDialog({
|
||||
</Button>
|
||||
</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="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" />
|
||||
@@ -347,14 +347,14 @@ export function WorkflowConfigDialog({
|
||||
value={titleFilter}
|
||||
onChange={(event) => setTitleFilter(event.target.value)}
|
||||
placeholder="按标题或检索词筛选..."
|
||||
className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 sm:text-sm"
|
||||
className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-100 dark:focus-visible:border-[#2b8ffe] dark:focus-visible:ring-[#2b8ffe]/25 sm:text-sm"
|
||||
/>
|
||||
{titleFilter && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="清空筛选"
|
||||
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" />
|
||||
</button>
|
||||
@@ -366,7 +366,7 @@ export function WorkflowConfigDialog({
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as ReportStructureStatusFilter)}
|
||||
aria-label="启用状态筛选"
|
||||
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm"
|
||||
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="enabled">已启用</option>
|
||||
@@ -380,7 +380,7 @@ export function WorkflowConfigDialog({
|
||||
value={modeFilter}
|
||||
onChange={(event) => setModeFilter(event.target.value as "all" | ReportStructureMode)}
|
||||
aria-label="大纲类型筛选"
|
||||
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm"
|
||||
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm"
|
||||
>
|
||||
<option value="all">全部大纲</option>
|
||||
<option value="adaptive">弹性大纲</option>
|
||||
@@ -391,11 +391,11 @@ export function WorkflowConfigDialog({
|
||||
</div>
|
||||
|
||||
<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} 套
|
||||
</span>
|
||||
<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={() => {
|
||||
onOpenChange(false);
|
||||
const base = location.pathname.replace(/\/+$/, "");
|
||||
@@ -409,9 +409,9 @@ export function WorkflowConfigDialog({
|
||||
</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 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 />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -420,8 +420,8 @@ export function WorkflowConfigDialog({
|
||||
{detail.title}
|
||||
</h2>
|
||||
{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="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden />
|
||||
<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 dark:bg-[#69b1ff]" aria-hidden />
|
||||
当前使用
|
||||
</span>
|
||||
)}
|
||||
@@ -440,7 +440,7 @@ export function WorkflowConfigDialog({
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<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}
|
||||
>
|
||||
返回结构库
|
||||
@@ -448,7 +448,7 @@ export function WorkflowConfigDialog({
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -460,7 +460,7 @@ export function WorkflowConfigDialog({
|
||||
{/* body */}
|
||||
{!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">
|
||||
{listQuery.isLoading ? (
|
||||
<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");
|
||||
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" />
|
||||
重置所有筛选
|
||||
@@ -528,12 +528,12 @@ export function WorkflowConfigDialog({
|
||||
</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">
|
||||
<span className="shrink-0">当前已选用:</span>
|
||||
{appliedStructureId ? (
|
||||
<span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#FA9196]">
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D]" />
|
||||
<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] dark:text-[#4096ff]" />
|
||||
<span className="truncate">
|
||||
{structures.find((item) => item.id === appliedStructureId)?.title ?? "当前结构"}
|
||||
({reportStructureModeLabel(structures.find((item) => item.id === appliedStructureId)?.structureMode ?? "adaptive")})
|
||||
@@ -546,7 +546,7 @@ export function WorkflowConfigDialog({
|
||||
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
完成
|
||||
@@ -554,11 +554,11 @@ export function WorkflowConfigDialog({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-slate-900">
|
||||
<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">
|
||||
<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">
|
||||
<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-[#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-[#263b5c] dark:bg-[#111c31]">
|
||||
<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 />
|
||||
</div>
|
||||
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
|
||||
@@ -570,9 +570,9 @@ export function WorkflowConfigDialog({
|
||||
</p>
|
||||
</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 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 />
|
||||
</div>
|
||||
<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) => (
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
@@ -598,17 +598,17 @@ export function WorkflowConfigDialog({
|
||||
)}
|
||||
</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 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 />
|
||||
</div>
|
||||
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
|
||||
推荐检索来源
|
||||
</span>
|
||||
</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} 个渠道
|
||||
</span>
|
||||
</div>
|
||||
@@ -617,7 +617,7 @@ export function WorkflowConfigDialog({
|
||||
{detail.retrievalSkills.map((skill) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -631,10 +631,10 @@ export function WorkflowConfigDialog({
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white 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-slate-800 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-[#263b5c] dark:bg-[#0f172a]">
|
||||
<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 />
|
||||
</div>
|
||||
<span className="truncate text-sm font-semibold text-neutral-900 dark:text-slate-100">
|
||||
@@ -646,7 +646,7 @@ export function WorkflowConfigDialog({
|
||||
<button
|
||||
type="button"
|
||||
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="复制大纲文本"
|
||||
>
|
||||
{copiedOutlineId === detail.id ? (
|
||||
@@ -661,15 +661,15 @@ export function WorkflowConfigDialog({
|
||||
</>
|
||||
)}
|
||||
</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="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden />
|
||||
<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 dark:bg-[#69b1ff]" aria-hidden />
|
||||
{reportStructureModeLabel(detail.structureMode)}
|
||||
</span>
|
||||
</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="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-slate-800">
|
||||
<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-[#263b5c]">
|
||||
<h3 className="min-w-0 truncate text-base font-bold tracking-tight text-neutral-900 dark:text-slate-100">
|
||||
{detailOutlineTitle}
|
||||
</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 ${
|
||||
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
|
||||
? "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/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/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-[#263b5c]/50 dark:bg-[#10192b]/70 dark:hover:border-[#38527a]"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
@@ -799,7 +799,7 @@ function StructureCard({
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{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]" />
|
||||
当前使用
|
||||
</span>
|
||||
@@ -807,18 +807,18 @@ function StructureCard({
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${
|
||||
structure.structureMode === "adaptive"
|
||||
? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#820014] dark:bg-[#5C0011]/40 dark:text-[#FA9196]"
|
||||
: "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-[#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-[#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)}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium ${
|
||||
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-slate-200 bg-slate-100 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
|
||||
? "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-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{structure.enabled ? "已启用" : "已停用"}
|
||||
@@ -841,7 +841,7 @@ function StructureCard({
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -850,7 +850,7 @@ function StructureCard({
|
||||
type="button"
|
||||
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}
|
||||
disabled={toggling}
|
||||
>
|
||||
@@ -860,7 +860,7 @@ function StructureCard({
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
@@ -884,7 +884,7 @@ function StructureCard({
|
||||
{directions.length > 0 && (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -892,13 +892,13 @@ function StructureCard({
|
||||
<span
|
||||
key={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}
|
||||
</span>
|
||||
))}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -908,7 +908,7 @@ function StructureCard({
|
||||
</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()}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -934,8 +934,8 @@ function StructureCard({
|
||||
disabled={toggling}
|
||||
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
|
||||
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 ${
|
||||
structure.enabled ? "bg-[#F5222D]" : "bg-slate-300 dark:bg-slate-700"
|
||||
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] dark:bg-[#1677ff]" : "bg-slate-300 dark:bg-[#2a4164]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -4,11 +4,12 @@ import test from "node:test";
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
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({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
@@ -19,7 +20,6 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
|
||||
buttons.map((button) => button.key),
|
||||
[
|
||||
"writing",
|
||||
"memory",
|
||||
"references",
|
||||
"contextCompression",
|
||||
"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", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: true,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
|
||||
@@ -15,6 +15,9 @@ type BuildIframeModeButtonsInput = {
|
||||
agentSelected: boolean;
|
||||
writingModeEnabled: boolean;
|
||||
memoryInjectionEnabled: boolean;
|
||||
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
|
||||
hideMemoryButton?: boolean;
|
||||
referenceModeEntryEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
ragModeLocked: boolean;
|
||||
contextCompressionEnabled: boolean;
|
||||
@@ -25,12 +28,14 @@ export function buildIframeModeButtons({
|
||||
agentSelected,
|
||||
writingModeEnabled,
|
||||
memoryInjectionEnabled,
|
||||
hideMemoryButton = true,
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}: BuildIframeModeButtonsInput): IframeModeButtonState[] {
|
||||
return [
|
||||
const buttons: IframeModeButtonState[] = [
|
||||
{
|
||||
key: "writing",
|
||||
label: "写作",
|
||||
@@ -63,4 +68,9 @@ export function buildIframeModeButtons({
|
||||
active: agentSelected,
|
||||
},
|
||||
];
|
||||
return buttons.filter(
|
||||
(button) =>
|
||||
(referenceModeEntryEnabled || button.key !== "references") &&
|
||||
(!hideMemoryButton || button.key !== "memory"),
|
||||
);
|
||||
}
|
||||
@@ -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 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 |
|
||||
| **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`. |
|
||||
| **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 |
|
||||
@@ -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.
|
||||
|
||||
### 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 按类型聚合)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -42,12 +42,17 @@ class PromptPrefixResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
|
||||
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")
|
||||
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):
|
||||
enabled: bool | None = None
|
||||
switch_label: str | None = None
|
||||
prompt_prefix: str | None = None
|
||||
ordinary_qa_markdown_format_enabled: bool | None = None
|
||||
|
||||
|
||||
class PluginDomainAgentMappingItem(BaseModel):
|
||||
@@ -82,10 +87,12 @@ class PrivateSquareUpdateRequest(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")
|
||||
|
||||
|
||||
class CitationDisplayUpdateRequest(BaseModel):
|
||||
reference_mode_enabled: bool | None = None
|
||||
cleanup_words: list[str] | None = None
|
||||
|
||||
|
||||
@@ -110,6 +117,7 @@ def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||
enabled=settings.enabled,
|
||||
switch_label=settings.switch_label,
|
||||
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)
|
||||
async def get_citation_display() -> CitationDisplayResponse:
|
||||
"""Return global citation display cleanup settings."""
|
||||
"""Return global citation display settings."""
|
||||
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)
|
||||
@@ -420,14 +431,22 @@ async def update_citation_display(
|
||||
body: CitationDisplayUpdateRequest,
|
||||
request: Request,
|
||||
) -> CitationDisplayResponse:
|
||||
"""Update global citation display cleanup settings. Admin only."""
|
||||
"""Update global citation display settings. Admin only."""
|
||||
await _require_admin(request)
|
||||
|
||||
current = load_system_settings()
|
||||
words = [str(item).strip() for item in (body.cleanup_words or []) if str(item).strip()]
|
||||
updated = current.citation_display.model_copy(update={"cleanup_words": words})
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
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}))
|
||||
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)
|
||||
|
||||
+15
-1
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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.types import Skill, SkillCategory
|
||||
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 = """
|
||||
<role>
|
||||
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_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 ""
|
||||
)
|
||||
|
||||
|
||||
+9
-1
@@ -153,6 +153,10 @@ class PromptPrefixSettings(BaseModel):
|
||||
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")
|
||||
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")
|
||||
|
||||
|
||||
@@ -255,8 +259,12 @@ class AppearanceSettings(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(
|
||||
default_factory=list,
|
||||
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:
|
||||
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_agent_soul", 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 notebook_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
|
||||
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 deerflow.agents.lead_agent import prompt as prompt_module
|
||||
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()
|
||||
assert settings.prompt_prefix.enabled is False
|
||||
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:
|
||||
@@ -36,6 +41,7 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
|
||||
enabled=True,
|
||||
switch_label="加入立场前缀",
|
||||
prompt_prefix="你需要站在中方角度",
|
||||
ordinary_qa_markdown_format_enabled=True,
|
||||
)
|
||||
)
|
||||
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.switch_label == "加入立场前缀"
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user