Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11926db5 | ||
|
|
1ffead7ee2 | ||
|
|
a0a75a9964 | ||
|
|
37964d1f95 | ||
|
|
a9d66536c3 | ||
|
|
a968c3b5cd | ||
|
|
e52261fc3d | ||
|
|
4450f342d8 | ||
|
|
213dd841b5 | ||
|
|
816c46d447 | ||
|
|
db857ddd4d | ||
|
|
9be0c6555f | ||
|
|
e475c37f1d | ||
|
|
d86c9d48a6 | ||
|
|
008c9ede6e | ||
|
|
151ecceb85 | ||
|
|
1555d5344b | ||
|
|
bd8ec41914 | ||
|
|
1e21e559fb | ||
|
|
e496221dc3 | ||
|
|
5bf3955b26 | ||
|
|
1ab3329f6d | ||
|
|
e70bb4aa6f | ||
|
|
b22d0200c3 | ||
|
|
fb73ae0471 | ||
|
|
75aad4eef6 | ||
|
|
cec2996861 | ||
|
|
239b798c5d | ||
|
|
f1e53ef5c0 | ||
|
|
a368037e81 | ||
|
|
59f2f56124 | ||
|
|
3779bb69ef | ||
|
|
25153dbd84 | ||
|
|
91ba47bf47 | ||
|
|
28bf1ac7b2 | ||
|
|
45df500a9c | ||
|
|
f32de510fe | ||
|
|
21722b9f5a | ||
|
|
f49def09ea | ||
|
|
4948a80c56 | ||
|
|
7d249a81ce | ||
|
|
1d07025f74 | ||
|
|
91c5d43c48 | ||
|
|
b60d9ed09e | ||
|
|
56740ecf56 | ||
|
|
9f788e37de | ||
|
|
0634337858 | ||
|
|
08ce3dda73 | ||
|
|
c8f69781e4 | ||
|
|
13bfa719ee | ||
|
|
359bd28f2c | ||
|
|
7bd032ec77 | ||
|
|
790267ac43 | ||
|
|
d9844e0180 | ||
|
|
6ade3b69b9 | ||
|
|
ae184f434d | ||
|
|
055523bf33 | ||
|
|
866bc0f8d8 | ||
|
|
1bc203b5cd | ||
|
|
1cd99a5a90 | ||
|
|
2ca8a23102 | ||
|
|
97ce19973d | ||
|
|
783774d3f3 | ||
|
|
85e119db08 | ||
|
|
5436719bfb | ||
|
|
e571caaf2c | ||
|
|
5d2997b6a0 | ||
|
|
f8cb7be18c | ||
|
|
b93b82fcaf | ||
|
|
d0b4832ed7 | ||
|
|
4ffd5a3c94 | ||
|
|
2d6992d656 | ||
|
|
bb7291a97c | ||
|
|
a699545c4c | ||
|
|
2c331aa744 | ||
|
|
82dd9fb08f | ||
|
|
b97aa77adc | ||
|
|
d7d101814f | ||
|
|
871d98e6f2 | ||
|
|
3cd093a8a2 | ||
|
|
4a59da7ff0 | ||
|
|
f485d4fff3 | ||
|
|
2b0ba2a68b | ||
|
|
c23350f17b | ||
|
|
124eb04a6b | ||
|
|
b4206d6421 | ||
|
|
8947223255 | ||
|
|
5a420189b1 | ||
|
|
ae31c50424 | ||
|
|
794aab1007 | ||
|
|
be06cac223 | ||
|
|
b0db8cc3d8 | ||
|
|
0dab5a8121 | ||
|
|
c012146c18 | ||
|
|
f75f4f9db1 | ||
|
|
0c663f5f7c | ||
|
|
d39b58255d | ||
|
|
cd2ca406a5 | ||
|
|
637be00946 | ||
|
|
0dd93421c4 | ||
|
|
e27321cb23 |
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Never send source-control history, local dependencies, runtime state, or
|
||||
# credentials to the Docker daemon. The offline images are built from the
|
||||
# repository root by deploy/1panel-offline/Dockerfile.*.
|
||||
.git
|
||||
.runtime
|
||||
.history
|
||||
tmp
|
||||
dist
|
||||
|
||||
# Credentials and environment-specific configuration are supplied at runtime
|
||||
# through the offline delivery package, never copied into an image layer.
|
||||
**/.env
|
||||
**/.env.*
|
||||
offline-backend-20260512/backend/.deer-flow
|
||||
offline-backend-20260512/backend/logs
|
||||
offline-backend-20260512/backend/docker-data
|
||||
offline-backend-20260512/backend/dist
|
||||
offline-backend-20260512/backend/wheelhouse
|
||||
offline-backend-20260512/backend/offline-wheels
|
||||
offline-backend-20260512/backend/.venv
|
||||
offline-backend-20260512/backend/.pytest_cache
|
||||
offline-backend-20260512/backend/.ruff_cache
|
||||
|
||||
# Reproducibly install frontend dependencies inside the image instead.
|
||||
frontend-web/node_modules
|
||||
frontend-web/dist
|
||||
frontend-web/.vite
|
||||
frontend-web/.offline-pack-build
|
||||
frontend-web/.offline-extract-test
|
||||
frontend-web/node_modules-offline-*.tar.gz
|
||||
|
||||
# Editor and operating-system metadata.
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/.DS_Store
|
||||
@@ -90,12 +90,13 @@ git-clone/
|
||||
# Repo-root offline dependency pack (local only)
|
||||
/dist/
|
||||
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
@@ -112,4 +113,4 @@ offline-backend-20260512/backend/uv.lock
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
@@ -37,6 +37,10 @@
|
||||
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
|
||||
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
|
||||
# VITE_MOCK_COP_TASK_DETAIL=false
|
||||
# VITE_POSITION_ROUNDTABLE_DEMO_MODE=false
|
||||
# true=免登录直达岗位分析,完全使用前端 JSON,不请求任何后台接口。
|
||||
# 仅限离线演示环境;生产环境请保持 false。
|
||||
# VITE_POSITION_ROUNDTABLE_STANDALONE_MODE=false
|
||||
|
||||
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
@@ -50,4 +54,6 @@
|
||||
# 可选
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
|
||||
# VITE_FORCE_COLLAPSED_SIDEBAR=false
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
@@ -0,0 +1,46 @@
|
||||
**Comparison target**
|
||||
|
||||
- Source visual truth (dark intelligent-agent list): `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-75f6ce1c-cd70-4b8c-a80a-a3d100105c69.png`
|
||||
- Additional source references: `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-00567484-ba8a-4595-b386-52e1467ae9cf.png` (segmented switch), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-02808803-05ba-4f2c-be50-c72b484a9f06.png` (business-chain node card), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-adf38fec-7baf-4d01-9c73-bdbae94b774e.png` (business-chain selector)
|
||||
- Implementation screenshots: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-light.png`, `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-dark.png`
|
||||
- Full-view comparison evidence: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-comparison.png`
|
||||
- Viewport: 1280 × 720. Light route: `#/page/strategy/qa/position-roundtable`; dark embedded route: `#/page/strategy/qa/position-roundtable?embed=1&theme=dark`.
|
||||
- State: local browser has no authenticated API session. The original intent API is invoked and reports `Authentication required`; business-chain and agent endpoints return no usable data.
|
||||
|
||||
**Findings**
|
||||
|
||||
- [P1] Populated agent and business-chain states cannot be compared yet.
|
||||
Location: right agent list and non-planning-role chain diagram.
|
||||
Evidence: reference images show populated cards and nodes; the implementation has `0 个` agents and no available business chain in the unauthenticated preview.
|
||||
Impact: card density, selected-card indicator, chain node flow, and agent-detail transition cannot be truthfully verified against live data.
|
||||
Fix: verify the route in an authenticated session with at least one public chain and tagged agents.
|
||||
|
||||
**Open Questions**
|
||||
|
||||
- The final business-chain stage arrangement is still pending product confirmation. The compact graph maps whatever stage configuration is returned, while intentionally omitting dependency-label text as requested.
|
||||
|
||||
**Implementation Checklist**
|
||||
|
||||
1. Left task panel is now display-only and retains its independent position-roundtable task adapter.
|
||||
2. Business-chain selection uses the original roundtable card/radio/relationship-entry pattern.
|
||||
3. Every position uses the original `useStep1Intent` interface and `Step1Panel` message list/input panel; only the position header changes.
|
||||
4. The right switch now uses the exact `课题研究 / ai写作` segmented-control treatment (light gray track, white active state; dark blue glass equivalent).
|
||||
5. The embedded layout widens the left panel to 360px and applies the deep-blue right-panel tokens used by the reference.
|
||||
6. Other roles render a taller top-to-bottom card-node flow without dependency labels. The canvas supports mouse-drag panning, and each node retains only a name plus a single-line description.
|
||||
|
||||
**Follow-up Polish**
|
||||
|
||||
- [P3] If live content is unusually long, tune the compact flow node width or horizontal scrolling after seeing the production chain data.
|
||||
|
||||
**Comparison history**
|
||||
|
||||
- Iteration 1: task fields were editable and the right TDesign card tabs looked unlike the reference. Replaced the task form with display-only information and replaced the tabs with the existing `课题研究 / ai写作` segmented switch.
|
||||
- Iteration 2: business-chain selection was a dropdown and the flow was vertical avatar chips. Replaced it with original-roundtable selection cards and compact business-chain configuration-style node cards with link lines but no dependency labels.
|
||||
- Iteration 3: dark right-panel background differed from the supplied deep-blue reference. Applied the `#032650` / `#042b59` / `#2b8ffe` token family and captured the revised embedded dark state in the full-view comparison.
|
||||
- Iteration 4: removed the custom disabled input from non-planning positions and reused the original roundtable input panel. Enlarged the left flow viewport, retained its connection lines, and removed node level/seat/status metadata.
|
||||
- Iteration 5: converted the compact flow from left-to-right to top-to-bottom. Added mouse-drag panning and single-line description truncation; populated-chain visual QA remains blocked by the unauthenticated preview.
|
||||
- Iteration 6: constrained the flow canvas to its card viewport, narrowed nodes from 190px to 158px, and realigned branch gaps plus connector anchors to prevent clipping and offset lines.
|
||||
|
||||
**Final result**
|
||||
|
||||
blocked
|
||||
@@ -0,0 +1,362 @@
|
||||
# 岗位会商交付闭环与内置智能体实现计划
|
||||
|
||||
> 页面:`/page/strategy/qa/position-roundtable`
|
||||
>
|
||||
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
|
||||
|
||||
## 实施核对(2026-07-21)
|
||||
|
||||
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
|
||||
|
||||
```text
|
||||
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
|
||||
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
|
||||
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
|
||||
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
|
||||
```
|
||||
|
||||
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
|
||||
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
|
||||
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
|
||||
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
|
||||
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
|
||||
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
|
||||
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
|
||||
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
|
||||
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
|
||||
|
||||
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
|
||||
|
||||
## 数据持久化与灾难恢复(2026-07-23)
|
||||
|
||||
岗位会商现在使用“两层持久化”,两层职责不同:
|
||||
|
||||
1. LangGraph checkpoint/thread 是智能体继续推理与流式问答的执行源。
|
||||
2. `position_roundtable_sessions` / `position_roundtable_nodes` 是页面历史的数据库恢复源。
|
||||
|
||||
数据库恢复源会保存:
|
||||
|
||||
- 情报分析岗完整消息、步骤时间线和未发送输入草稿;
|
||||
- 每个业务链岗位的完整消息和未发送输入草稿;
|
||||
- 方案总结、行动规划两个收口智能体的完整消息和未发送输入草稿;
|
||||
- 已确认意图、冻结任务与业务链快照、节点状态、版本、驳回记录;
|
||||
- Markdown / JSON 等文本产物正文(单文件最多 400 万字符)和文件元数据。
|
||||
|
||||
因此即使线上重启后 checkpoint 或沙箱 outputs 卷丢失,历史页仍能从数据库展示对话与产物正文,后续收口智能体也能继续读取数据库中的有效上游交付。新任务在意图识别开始前就会创建 Session,用户不必等到“启动业务链”才获得持久化保护。历史下拉支持删除,删除会显式清理 Session 与全部节点恢复快照,不依赖 SQLite 外键开关。
|
||||
|
||||
对应迁移:`20260723_05_position_roundtable_conversations.py`。部署时必须执行到 Alembic head。
|
||||
|
||||
## 0. 核心结论
|
||||
|
||||
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
|
||||
- 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。
|
||||
- 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。
|
||||
- 行动规划智能体建议作为新的内置智能体新增。
|
||||
- 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。
|
||||
- 输出:行动规划报告、结构化子任务清单。
|
||||
- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。
|
||||
- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。
|
||||
|
||||
## 1. 目标业务流程
|
||||
|
||||
1. 情报分析岗进入页面,查看任务信息并完成意图识别。
|
||||
2. 用户点击“确认意图并启动业务链”。
|
||||
3. 启动前做校验:
|
||||
- 未选择业务链:提示“请先选择业务链”。
|
||||
- 业务链存在未配置岗位的席位:提示并引导去业务链配置页。
|
||||
- 业务链席位缺少智能体:提示具体席位名称。
|
||||
- 意图尚未完成:提示先完成任务意图识别。
|
||||
4. 校验通过后冻结业务链快照,初始化岗位会商节点。
|
||||
5. 各岗位只对分配给本岗位的智能体进行问答。
|
||||
6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。
|
||||
7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。
|
||||
8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。
|
||||
9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。
|
||||
10. 方案总结完成后开放“行动规划”。
|
||||
11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。
|
||||
12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。
|
||||
|
||||
## 2. 状态设计
|
||||
|
||||
### 2.1 节点状态
|
||||
|
||||
现有节点状态需要扩展或映射为更贴近页面的展示状态:
|
||||
|
||||
| 展示状态 | 建议内部状态 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 |
|
||||
| 进行中 | `running` | 当前智能体正在回答或产物正在生成 |
|
||||
| 已完成 | `done` | 当前节点已有有效交付 |
|
||||
| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 |
|
||||
| 异常 | `error` | 生成或保存失败 |
|
||||
|
||||
建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。
|
||||
|
||||
### 2.2 产物状态
|
||||
|
||||
产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| `pending` | 还没有交付 |
|
||||
| `running` | 正在生成 |
|
||||
| `delivered` | 已交付且当前有效 |
|
||||
| `rejected` | 被用户驳回 |
|
||||
| `stale` | 受上游驳回或重做影响,已经失效 |
|
||||
|
||||
### 2.3 内置收口节点
|
||||
|
||||
在流程图中把两个内置节点追加到业务链末尾:
|
||||
|
||||
1. `builtin:summary`:方案总结智能体。
|
||||
2. `builtin:action-plan`:行动规划智能体。
|
||||
|
||||
这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。
|
||||
|
||||
## 3. 数据模型调整
|
||||
|
||||
### 3.1 Session 扩展
|
||||
|
||||
`position_roundtable_sessions` 已使用下列持久化字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
|
||||
| `summary_thread_id` | 方案总结智能体线程 |
|
||||
| `summary_snapshot` | 最新有效总结报告快照 |
|
||||
| `action_plan_thread_id` | 行动规划智能体线程 |
|
||||
| `action_plan_snapshot` | 最新有效行动规划快照 |
|
||||
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
|
||||
|
||||
### 3.2 Node 扩展
|
||||
|
||||
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
|
||||
| `rejection_count` | 当前节点累计驳回次数 |
|
||||
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
|
||||
| `revision` | 当前有效交付版本 |
|
||||
| `invalidated_by` | 导致当前节点失效的上游节点 key |
|
||||
|
||||
### 3.3 Deliverable 新表(后续可选)
|
||||
|
||||
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `id` | 产物 ID |
|
||||
| `session_id` | 会话 ID |
|
||||
| `node_key` | 来源节点 |
|
||||
| `agent_id` | 来源智能体 |
|
||||
| `position_id` | 来源岗位 |
|
||||
| `title` | 产物名称 |
|
||||
| `kind` | `answer / file / summary / action_plan` |
|
||||
| `content` | 文本产物内容摘要或正文 |
|
||||
| `artifact_path` | 文件产物虚拟路径 |
|
||||
| `thread_id` | 所属线程 |
|
||||
| `status` | `pending / running / delivered / rejected / stale` |
|
||||
| `revision` | 来源节点版本 |
|
||||
| `rejection_title` | 驳回标题 |
|
||||
| `rejection_reason` | 驳回理由 |
|
||||
| `created_at` | 创建时间 |
|
||||
| `updated_at` | 更新时间 |
|
||||
|
||||
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
|
||||
|
||||
## 4. 驳回与级联失效规则
|
||||
|
||||
### 4.1 基本驳回
|
||||
|
||||
用户点击产物卡片的“驳回”后弹窗填写:
|
||||
|
||||
- 驳回标题。
|
||||
- 驳回理由。
|
||||
|
||||
提交后:
|
||||
|
||||
- 当前产物状态改为 `rejected`。
|
||||
- 当前节点状态改为 `rejected` 或 `ready`。
|
||||
- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。
|
||||
- 记录 `last_rejection`,用于页面展示和后续追踪。
|
||||
|
||||
### 4.2 级联失效
|
||||
|
||||
如果驳回的节点位于第 N 阶段:
|
||||
|
||||
- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。
|
||||
- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。
|
||||
- 后续节点状态回到未完成展示态。
|
||||
- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。
|
||||
- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。
|
||||
|
||||
### 4.3 重新交付
|
||||
|
||||
被驳回节点重新问答并完成后:
|
||||
|
||||
- 当前节点产生新的 `revision`。
|
||||
- 当前节点产物状态变为 `delivered`。
|
||||
- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。
|
||||
- 方案总结和行动规划必须基于最新有效产物重新生成。
|
||||
|
||||
## 5. 接口规划
|
||||
|
||||
### 5.1 启动与校验
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 |
|
||||
|
||||
第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。
|
||||
|
||||
### 5.2 产物
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||
|
||||
### 5.3 方案总结与行动规划
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
|
||||
|
||||
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
|
||||
|
||||
## 6. 前端实施步骤
|
||||
|
||||
### 阶段 1:状态与启动校验(已完成)
|
||||
|
||||
- 扩展 `PositionNodeStatus` 和状态显示映射。
|
||||
- “确认意图并启动业务链”前补齐校验提示。
|
||||
- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。
|
||||
- 左侧流程图节点右上角添加状态小图标。
|
||||
- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。
|
||||
|
||||
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
|
||||
|
||||
### 阶段 2:流程图弹窗(已完成)
|
||||
|
||||
- 左侧保留小型从上到下流程图。
|
||||
- 流程图右上角增加“查看”按钮。
|
||||
- 点击后打开弹窗,展示更大的流程图。
|
||||
- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。
|
||||
|
||||
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
|
||||
|
||||
### 阶段 3:产物卡片与预览(已完成)
|
||||
|
||||
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
|
||||
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
|
||||
- 点击卡片打开详情弹窗。
|
||||
- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。
|
||||
- 每个有效产物提供“驳回”入口。
|
||||
|
||||
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
|
||||
|
||||
### 阶段 4:驳回弹窗与级联失效(已完成)
|
||||
|
||||
- 新增 `RejectDeliverableDialog`。
|
||||
- 驳回表单包含标题和理由,均必填。
|
||||
- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。
|
||||
- 前端刷新 Session、Node、Deliverable 和流程图状态。
|
||||
- 被驳回节点在对话区显示驳回信息,提示用户重新交付。
|
||||
|
||||
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
|
||||
|
||||
### 阶段 5:方案总结智能体(已完成)
|
||||
|
||||
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
|
||||
- 输入材料改为岗位会商材料:
|
||||
- 任务信息。
|
||||
- 情报分析岗意图结果。
|
||||
- 业务链快照。
|
||||
- 所有状态为 `delivered` 的岗位产物。
|
||||
- 已驳回或失效产物只作为历史参考,不进入默认总结材料。
|
||||
- 方案总结节点追加到流程图末尾。
|
||||
- 所有产物有效交付后才允许点击“生成方案总结”。
|
||||
- 总结结果写入 `summary_snapshot`,同时进入产物列表。
|
||||
|
||||
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
|
||||
|
||||
### 阶段 6:行动规划智能体(已完成)
|
||||
|
||||
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
|
||||
- 新增行动规划 prompt:
|
||||
- 读取方案总结报告。
|
||||
- 读取各岗位最终有效产物。
|
||||
- 拆解为若干子任务。
|
||||
- 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。
|
||||
- 输出两类产物:
|
||||
- `行动规划报告.md`。
|
||||
- `action-plan-subtasks.json`。
|
||||
- 行动规划节点追加到方案总结之后。
|
||||
- 方案总结有效完成后才允许启动行动规划。
|
||||
|
||||
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
|
||||
|
||||
### 阶段 7:返回修改与重新生成(已完成)
|
||||
|
||||
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
|
||||
- 驳回后自动显示需要重做的节点。
|
||||
- 重做节点完成后,允许重新生成方案总结和行动规划。
|
||||
- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。
|
||||
|
||||
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
|
||||
|
||||
### 阶段 8:测试与回归(已完成)
|
||||
|
||||
- 后端测试:
|
||||
- activate 校验。
|
||||
- 节点完成后产物入库。
|
||||
- 驳回当前节点。
|
||||
- 驳回第一阶段时后续阶段级联失效。
|
||||
- 总结和行动规划失效。
|
||||
- 前端测试:
|
||||
- 启动校验提示。
|
||||
- 状态图标显示。
|
||||
- 产物卡片筛选和预览。
|
||||
- 驳回弹窗必填校验。
|
||||
- 回归验证:
|
||||
- 原多智能体会商页面不受影响。
|
||||
- 业务链普通配置入口不显示岗位配置。
|
||||
- 岗位会商页面刷新恢复正常。
|
||||
- 深色模式和嵌入模式布局不溢出。
|
||||
|
||||
## 7. 推荐提交拆分
|
||||
|
||||
1. `feat(position-roundtable): add delivery status model`
|
||||
2. `feat(position-roundtable): validate chain activation`
|
||||
3. `feat(position-roundtable): add workflow status graph`
|
||||
4. `feat(position-roundtable): add deliverable cards and preview`
|
||||
5. `feat(position-roundtable): support rejection cascade`
|
||||
6. `feat(position-roundtable): add summary builtin node`
|
||||
7. `feat(position-roundtable): add action planning builtin node`
|
||||
8. `test(position-roundtable): cover delivery workflow`
|
||||
|
||||
## 8. 第一轮建议范围
|
||||
|
||||
第一轮优先做最小可闭环:
|
||||
|
||||
```text
|
||||
意图识别
|
||||
→ 启动校验
|
||||
→ 业务链节点状态
|
||||
→ 节点产物列表
|
||||
→ 产物详情查看
|
||||
→ 驳回与级联失效
|
||||
→ 全部交付后生成方案总结
|
||||
→ 基于总结生成行动规划
|
||||
```
|
||||
|
||||
如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>CM 助手</title>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script src="/runtime-config.js" data-runtime-config></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -104,6 +105,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lightningcss": "^1.30.2",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -460,37 +462,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
||||
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
@@ -1732,11 +1708,12 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"langium": "^4.0.0"
|
||||
"@chevrotain/types": "~11.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
@@ -4274,6 +4251,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -5650,32 +5687,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz",
|
||||
"integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
@@ -6547,6 +6558,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -7354,7 +7375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.28",
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
@@ -7372,23 +7395,6 @@
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz",
|
||||
"integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==",
|
||||
"dependencies": {
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.3",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.0.tgz",
|
||||
@@ -8100,43 +8106,32 @@
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||
"version": "11.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz",
|
||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.0",
|
||||
"@mermaid-js/parser": "^1.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape": "^3.33.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.25",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.45",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/uuid": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
@@ -10414,49 +10409,6 @@
|
||||
"vite": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -7,6 +7,30 @@
|
||||
// 4. 这里的默认值来自 .env.network;如某个环境不用某项,可以留空字符串。
|
||||
// 5. 布尔开关也用字符串形式填写:"true" / "false"。
|
||||
window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 品牌图片相对路径会以 runtime-config.js 所在目录为基准解析。
|
||||
// Nginx 以子路径部署时不需要修改前端图片路径。
|
||||
// 打包后可直接替换 dist/brand-assets/ 内的同名 PNG。
|
||||
// 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。
|
||||
// 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。
|
||||
VITE_WELCOME_LOGO_VISIBLE: "false",
|
||||
// 深色主题顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。
|
||||
VITE_DARK_LOGO_POSITION: "center",
|
||||
// 红白主题的顶栏系统 Logo 图片地址。
|
||||
VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png",
|
||||
// 浅蓝主题的顶栏系统 Logo 图片地址。
|
||||
VITE_BRAND_LOGO_LIGHTBLUE_URL: "brand-assets/brand-logo-lightblue.png",
|
||||
// 绿色主题可单独替换 Logo;默认复用浅色 Logo。
|
||||
VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png",
|
||||
// 深色主题的顶栏系统 Logo 图片地址。
|
||||
VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png",
|
||||
// 红白主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
|
||||
VITE_BRAND_WELCOME_LIGHT_URL: "",
|
||||
// 浅蓝主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
|
||||
VITE_BRAND_WELCOME_LIGHTBLUE_URL: "",
|
||||
VITE_BRAND_WELCOME_LIGHTGREEN_URL: "",
|
||||
// 深色主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
|
||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||
|
||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
||||
|
||||
@@ -16,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
|
||||
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
||||
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
||||
VITE_ROUTE_LOGIN_PASSWORD: "",
|
||||
|
||||
// 轻应用「新建笔记」系列接口地址。
|
||||
VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000",
|
||||
|
||||
@@ -71,7 +99,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
|
||||
|
||||
// MaxKey 鉴权地址:onlineTicket 换 token 使用。
|
||||
VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn",
|
||||
VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn",
|
||||
|
||||
// PG 中任务 iframe 地址。
|
||||
VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn",
|
||||
@@ -85,12 +113,23 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
|
||||
VITE_MOCK_COP_TASK_DETAIL: "false",
|
||||
|
||||
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
|
||||
// 默认关闭,避免演示历史记录出现在正常生产账号中。
|
||||
VITE_POSITION_ROUNDTABLE_DEMO_MODE: "false",
|
||||
// 纯前端岗位分析演示:跳过登录,直接进入岗位分析页,并禁止所有后台请求。
|
||||
// 仅用于离线展台/演示环境,生产环境必须保持 false。
|
||||
VITE_POSITION_ROUNDTABLE_STANDALONE_MODE: "false",
|
||||
|
||||
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
|
||||
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
|
||||
|
||||
// 静态站点模式开关;一般部署业务系统时保持 false。
|
||||
VITE_STATIC_WEBSITE_ONLY: "false",
|
||||
|
||||
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
|
||||
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
|
||||
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
|
||||
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
};
|
||||
@@ -37,6 +37,11 @@ import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
@@ -46,6 +51,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const publicEmbedTheme = usePublicEmbedTheme();
|
||||
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
|
||||
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
|
||||
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
|
||||
useEffect(() => {
|
||||
@@ -54,14 +60,18 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
|
||||
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
|
||||
useEffect(() => {
|
||||
if (positionStandaloneMode) return;
|
||||
void fetchYLogConfig();
|
||||
}, []);
|
||||
}, [positionStandaloneMode]);
|
||||
const forcedTheme = useMemo(() => {
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
|
||||
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
|
||||
const embedTheme = resolveForcedEmbedTheme(location.search);
|
||||
if (embedTheme) return embedTheme;
|
||||
const routeTheme = resolveForcedThemeForPath(location.pathname);
|
||||
const routeTheme = resolveForcedThemeForPath(
|
||||
location.pathname,
|
||||
location.search,
|
||||
);
|
||||
if (routeTheme) return routeTheme;
|
||||
if (location.pathname === "/embed/roundtable") {
|
||||
const wp = readWujieProps();
|
||||
@@ -86,7 +96,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
attribute="class"
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="light"
|
||||
themes={["light", "dark", "lightblue"]}
|
||||
themes={["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"]}
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
@@ -95,8 +105,8 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
<DesensitizeWordsLoader />
|
||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
@@ -114,17 +124,38 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
function RootRedirect() {
|
||||
const { search } = useLocation();
|
||||
if (isPositionRoundtableStandaloneMode()) {
|
||||
return <Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />;
|
||||
}
|
||||
return <Navigate to={`/login${search}`} replace />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
|
||||
const standaloneLoginRedirect = (
|
||||
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
|
||||
);
|
||||
const pageElement = positionStandaloneMode ? (
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome />
|
||||
</ChatRuntime>
|
||||
) : (
|
||||
<PageRoutes />
|
||||
);
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/login/:username"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||
@@ -161,7 +192,7 @@ export function App() {
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
|
||||
<defs>
|
||||
<style>
|
||||
.hit { fill: rgba(255, 255, 255, 0.8); opacity: 0; }
|
||||
.mark { fill: #fff; }
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(-2434.135 -67.941)">
|
||||
<rect class="hit" width="12" height="12" transform="translate(2434.135 67.941)"/>
|
||||
<path class="mark" d="M.885,0,0,.885,3.692,4.577,4.135,5l.442-.423L8.269.885,7.385,0,4.135,3.25Z" transform="translate(2436 71.942)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#fff;stroke-linejoin:round;}.b{stroke-linecap:round;}</style></defs><g transform="translate(-1)"><rect class="a" width="16" height="16" transform="translate(1)"/><path class="b" d="M7,42H19" transform="translate(-4 -28)"/><path class="b" d="M0,0H3" transform="translate(9.848 3.848) rotate(45)"/><path class="c" d="M11,12.907v2.427h2.439l6.894-6.9L17.9,6Z" transform="translate(-6.667 -4)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<style>
|
||||
.background { fill: none; }
|
||||
.mark { stroke: rgba(0, 0, 0, 0); stroke-miterlimit: 10; fill: url(#gradient); }
|
||||
</style>
|
||||
<linearGradient id="gradient" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox">
|
||||
<stop offset="0" stop-color="#a2e1f9"/>
|
||||
<stop offset="1" stop-color="#45c3f3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="background" width="16" height="16"/>
|
||||
<path class="mark" d="M14554.5,10202.5a1,1,0,1,1,0-2h5a1,1,0,0,1,0,2Zm9.8-.482-2.95-2.157a.445.445,0,0,1-.187-.361.46.46,0,0,1,.187-.366l2.95-2.152a.45.45,0,0,1,.716.365v4.307a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,14564.3,10202.019Zm-9.8-3.521a1,1,0,1,1,0-2h5a1,1,0,1,1,0,2Zm0-4a1,1,0,1,1,0-2h10a1,1,0,1,1,0,2Z" transform="translate(-14551.5 -10189.5)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 913 B |
@@ -8,6 +8,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||
.sidebar-contnet--header-hidden {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-contnet--header-hidden .item-nav-content {
|
||||
height: calc(100vh - 20px);
|
||||
min-height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
width: 98px;
|
||||
min-height: calc(100% - 20px);
|
||||
@@ -564,7 +575,59 @@
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Ice blue has a deliberately light banner. Keep top-level navigation and
|
||||
its active marker dark enough to remain readable against #9BCEF1. */
|
||||
html.iceblue .light-topnav-item {
|
||||
color: rgba(24, 50, 74, 0.78);
|
||||
}
|
||||
|
||||
html.iceblue .light-topnav-item:hover,
|
||||
html.iceblue .light-topnav-item--active,
|
||||
html.iceblue .light-topnav-item--active:hover {
|
||||
color: #18324a;
|
||||
}
|
||||
|
||||
html.iceblue .light-topnav-item--active::after {
|
||||
background: #3d6e96;
|
||||
}
|
||||
|
||||
/* Pale yellow has the same light-banner contrast requirement as ice blue. */
|
||||
html.lightyellow .light-topnav-item {
|
||||
color: rgba(48, 61, 35, 0.78);
|
||||
}
|
||||
|
||||
html.lightyellow .light-topnav-item:hover,
|
||||
html.lightyellow .light-topnav-item--active,
|
||||
html.lightyellow .light-topnav-item--active:hover {
|
||||
color: #303d23;
|
||||
}
|
||||
|
||||
html.lightyellow .light-topnav-item--active::after {
|
||||
background: #4c6330;
|
||||
}
|
||||
|
||||
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
||||
/* Runtime themes use their configurable header gradient rather than the
|
||||
red/blue banner artwork. Navigation copy follows an automatically chosen
|
||||
contrasting foreground so both pale and deep brand colours remain legible. */
|
||||
html[data-custom-theme="true"] .light-topnav-item,
|
||||
html[data-custom-theme="true"] .top-submenu-item {
|
||||
color: var(--navigation-foreground);
|
||||
}
|
||||
|
||||
html[data-custom-theme="true"] .light-topnav-item:hover,
|
||||
html[data-custom-theme="true"] .light-topnav-item--active,
|
||||
html[data-custom-theme="true"] .light-topnav-item--active:hover,
|
||||
html[data-custom-theme="true"] .top-submenu-item:hover,
|
||||
html[data-custom-theme="true"] .top-submenu-item--active {
|
||||
color: var(--navigation-foreground);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
background: var(--navigation-foreground);
|
||||
}
|
||||
|
||||
.light-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -587,6 +650,12 @@
|
||||
width: 236px;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||
.light-sidebar--header-hidden {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||
.light-sidebar__primary {
|
||||
flex: 0 0 96px;
|
||||
|
||||
@@ -17,9 +17,15 @@ type PageSidebarDarkProps = {
|
||||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
/** 全局 Header 隐藏时,侧边栏从视口顶部开始占满高度。 */
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
|
||||
export function PageSidebarDark({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden = false,
|
||||
}: PageSidebarDarkProps) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const inWujieEnv = isWujieEnv();
|
||||
@@ -196,7 +202,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
</div>
|
||||
);
|
||||
|
||||
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
|
||||
// 收起时主侧栏不占宽度,保留左下角入口以便随时恢复。
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
@@ -207,13 +213,17 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<PanelLeftOpen className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
||||
<span className="sidebar-collapse-btn__label">收起面板</span>
|
||||
<span className="sidebar-collapse-btn__label">展开面板</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
|
||||
<aside
|
||||
className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"} ${
|
||||
headerHidden ? "sidebar-contnet--header-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="sidebar-contents">
|
||||
{sidebarMenuItemsToRender.map((item) => (
|
||||
<div
|
||||
@@ -244,7 +254,6 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */}
|
||||
<button
|
||||
type="button"
|
||||
title="收起侧边栏"
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Gauge,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
FileText,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
@@ -49,6 +51,8 @@ import "@/components/page-sdebar.css";
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
/** Global Header is hidden but this sidebar remains available. */
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
@@ -79,23 +83,28 @@ const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
||||
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
||||
* (保证嵌入模式下所有层级仍可达,零回归)。
|
||||
*/
|
||||
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden = false,
|
||||
}: PageSidebarProps) {
|
||||
if (isWujieEnv()) {
|
||||
return (
|
||||
<LegacyAccordionSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
headerHidden={headerHidden}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <SecondaryNavSidebar />;
|
||||
return <SecondaryNavSidebar headerHidden={headerHidden} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||||
*/
|
||||
function SecondaryNavSidebar() {
|
||||
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { t } = useI18n();
|
||||
@@ -150,7 +159,11 @@ function SecondaryNavSidebar() {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
|
||||
<aside
|
||||
className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""} ${
|
||||
headerHidden ? "light-sidebar--header-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{/* 一级列:二级图标卡片 + 底部设置 */}
|
||||
<div className="light-sidebar__primary">
|
||||
<nav className="light-sidebar__list" aria-label="二级菜单">
|
||||
@@ -224,6 +237,22 @@ function SecondaryNavSidebar() {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||
>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
岗位角色配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
||||
@@ -287,6 +316,7 @@ function SecondaryNavSidebar() {
|
||||
function LegacyAccordionSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden,
|
||||
}: PageSidebarProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -438,7 +468,9 @@ function LegacyAccordionSidebar({
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
|
||||
} ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
@@ -526,6 +558,20 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
岗位角色配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"cursor-pointer bg-primary text-[var(--button-foreground,var(--primary-foreground))] hover:bg-primary/90",
|
||||
destructive:
|
||||
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
|
||||
@@ -71,15 +71,17 @@ const DialogOverlay = React.forwardRef<
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -57,18 +57,21 @@ function useSidebarSafe() {
|
||||
return React.useContext(SidebarContext);
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
disableToggle = false,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Keep a controlled deployment-level rail from being reopened by hotkeys. */
|
||||
disableToggle?: boolean;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
@@ -92,10 +95,11 @@ function SidebarProvider({
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
if (disableToggle) return;
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [disableToggle, isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, ListTree } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "./wiki.css";
|
||||
|
||||
function stripMarkdownToText(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/~~~[\s\S]*?~~~/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^[>\-*+\d.)\s]+/gm, "")
|
||||
.replace(/[`*_~|]/g, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getMarkdownExcerpt(markdown: string): string {
|
||||
const text = stripMarkdownToText(markdown);
|
||||
if (!text) return "";
|
||||
return text.length > 150 ? `${text.slice(0, 150)}...` : text;
|
||||
}
|
||||
|
||||
function countReadableWords(markdown: string): number {
|
||||
const text = stripMarkdownToText(markdown);
|
||||
if (!text) return 0;
|
||||
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0;
|
||||
return cjk + words;
|
||||
}
|
||||
|
||||
function selectorForId(id: string): string {
|
||||
const css = window.CSS;
|
||||
if (css?.escape) return `#${css.escape(id)}`;
|
||||
return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
|
||||
}
|
||||
|
||||
export function ReportMarkdownView({
|
||||
content,
|
||||
title,
|
||||
subtitle,
|
||||
badgeLabel = "报告阅读",
|
||||
className,
|
||||
compactHeader = false,
|
||||
}: {
|
||||
content: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
badgeLabel?: string;
|
||||
className?: string;
|
||||
compactHeader?: boolean;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const headings = useMemo(
|
||||
() => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3),
|
||||
[anchorResolver],
|
||||
);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]);
|
||||
const sectionCount = headings.length;
|
||||
const wordCount = useMemo(() => countReadableWords(content), [content]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHeadingId(headings[0]?.id ?? null);
|
||||
}, [headings]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || headings.length === 0) return;
|
||||
|
||||
const targets = headings
|
||||
.map((item) => root.querySelector<HTMLElement>(selectorForId(item.id)))
|
||||
.filter((item): item is HTMLElement => !!item);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (visible?.target.id) setActiveHeadingId(visible.target.id);
|
||||
},
|
||||
{
|
||||
root: root.closest("[data-radix-scroll-area-viewport]") ?? null,
|
||||
rootMargin: "-96px 0px -65% 0px",
|
||||
threshold: [0, 1],
|
||||
},
|
||||
);
|
||||
|
||||
targets.forEach((target) => observer.observe(target));
|
||||
return () => observer.disconnect();
|
||||
}, [headings, content]);
|
||||
|
||||
const scrollToHeading = useCallback((id: string) => {
|
||||
const root = rootRef.current;
|
||||
const target =
|
||||
root?.querySelector<HTMLElement>(selectorForId(id)) ?? document.getElementById(id);
|
||||
setActiveHeadingId(id);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("report-markdown-shell grid min-h-0 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]", className)}
|
||||
>
|
||||
<article className="report-markdown-paper min-w-0 overflow-hidden">
|
||||
<header className={cn("report-markdown-header", compactHeader && "report-markdown-header-compact")}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="report-markdown-badge">
|
||||
<BookOpen className="size-3.5" />
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
{subtitle && (
|
||||
<span className="max-w-full truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{title && <h1 className="report-markdown-title">{title}</h1>}
|
||||
{!compactHeader && excerpt && <p className="report-markdown-excerpt">{excerpt}</p>}
|
||||
<div className="report-markdown-metrics">
|
||||
<span>{sectionCount || 1} 个章节</span>
|
||||
<span>约 {wordCount} 字</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="wiki-markdown report-markdown-body">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="hidden min-w-0 lg:block">
|
||||
<div className="report-markdown-toc">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
<ListTree className="size-4" />
|
||||
内容目录
|
||||
</div>
|
||||
<span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{sectionCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
{headings.length > 0 ? (
|
||||
<nav className="report-markdown-toc-list">
|
||||
{headings.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"report-markdown-toc-item",
|
||||
activeHeadingId === item.id && "report-markdown-toc-item-active",
|
||||
item.level === 2 && "report-markdown-toc-level-2",
|
||||
item.level === 3 && "report-markdown-toc-level-3",
|
||||
)}
|
||||
title={item.text}
|
||||
onClick={() => scrollToHeading(item.id)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
暂无标题目录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,340 @@
|
||||
border-color: #eef1f5;
|
||||
}
|
||||
|
||||
.public-report-markdown {
|
||||
color: #334155;
|
||||
font-feature-settings: "kern";
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.public-report-markdown h1 {
|
||||
font-size: 30px;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.public-report-markdown h2 {
|
||||
font-size: 22px;
|
||||
margin-top: 42px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.public-report-markdown h3 {
|
||||
font-size: 18px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.public-report-markdown p,
|
||||
.public-report-markdown li {
|
||||
font-size: 16px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.public-report-markdown p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.public-report-markdown ul,
|
||||
.public-report-markdown ol {
|
||||
margin: 14px 0 18px;
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.public-report-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.public-report-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.public-report-markdown li {
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.public-report-markdown code {
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
color: #be123c;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.public-report-markdown pre {
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.public-report-markdown pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.public-report-markdown table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
.public-report-markdown th,
|
||||
.public-report-markdown td {
|
||||
min-width: 120px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.public-report-markdown blockquote {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.dark .public-report-markdown {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .public-report-markdown code {
|
||||
background: #1e293b;
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
.dark .public-report-markdown pre {
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.dark .public-report-markdown table {
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.report-markdown-shell {
|
||||
align-items: start;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.report-markdown-paper {
|
||||
border: 1px solid rgba(203, 213, 225, 0.85);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0) 220px),
|
||||
#ffffff;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.04),
|
||||
0 18px 44px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.report-markdown-header {
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
padding: 26px 34px 22px;
|
||||
}
|
||||
|
||||
.report-markdown-header-compact {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.report-markdown-badge {
|
||||
gap: 6px;
|
||||
border: 1px solid #dbe3ef;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.report-markdown-title {
|
||||
margin: 14px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.report-markdown-excerpt {
|
||||
margin: 14px 0 0;
|
||||
max-width: 840px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.report-markdown-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.report-markdown-metrics span {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.report-markdown-body {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 42px 44px;
|
||||
}
|
||||
|
||||
.report-markdown-body h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.report-markdown-body h2 {
|
||||
position: relative;
|
||||
margin-top: 44px;
|
||||
border-bottom-color: #e5edf7;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.report-markdown-body h2::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: 0.34em;
|
||||
width: 4px;
|
||||
height: 1.05em;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.report-markdown-body h3 {
|
||||
color: #1e293b;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.report-markdown-body p,
|
||||
.report-markdown-body li {
|
||||
font-size: 16px;
|
||||
line-height: 1.92;
|
||||
}
|
||||
|
||||
.report-markdown-body blockquote {
|
||||
border-left-color: #2563eb;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.report-markdown-body table {
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.report-markdown-toc {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(203, 213, 225, 0.82);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 14px;
|
||||
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.report-markdown-toc-list {
|
||||
max-height: calc(100vh - 128px);
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.report-markdown-toc-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 7px 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.report-markdown-toc-item:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.report-markdown-toc-item-active {
|
||||
border-left-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-markdown-toc-level-2 {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.report-markdown-toc-level-3 {
|
||||
padding-left: 30px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .report-markdown-paper {
|
||||
border-color: #1f2937;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.9), rgba(2, 6, 23, 0) 220px),
|
||||
#020617;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.dark .report-markdown-header {
|
||||
border-bottom-color: #1e293b;
|
||||
}
|
||||
|
||||
.dark .report-markdown-badge,
|
||||
.dark .report-markdown-metrics span {
|
||||
border-color: #334155;
|
||||
background: #0f172a;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .report-markdown-title {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .report-markdown-excerpt,
|
||||
.dark .report-markdown-metrics {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .report-markdown-body h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .report-markdown-body h2::before {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.dark .report-markdown-body blockquote {
|
||||
background: #0f172a;
|
||||
border-left-color: #60a5fa;
|
||||
}
|
||||
|
||||
.dark .report-markdown-toc {
|
||||
border-color: #1f2937;
|
||||
background: rgba(2, 6, 23, 0.88);
|
||||
}
|
||||
|
||||
.dark .report-markdown-toc-item:hover {
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .report-markdown-toc-item-active {
|
||||
background: rgba(37, 99, 235, 0.16);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* Anchor target offset so headings clear the floating "目录" pill. */
|
||||
.wiki-markdown h1,
|
||||
.wiki-markdown h2,
|
||||
|
||||
@@ -24,12 +24,60 @@ const AGENT_COLORS_DARK = [
|
||||
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
|
||||
] as const;
|
||||
|
||||
/** 按 agent id 稳定散列出一对渐变色(from → to)。`isDark` 切换深蓝/浅色调色板。 */
|
||||
export function agentColor(id: string, isDark: boolean) {
|
||||
/** 按 agent id 稳定散列出一对渐变色,并根据当前主题返回可读的图标前景色。 */
|
||||
const AGENT_COLORS_ICEBLUE = [
|
||||
{ from: "#eff8fd", to: "#b7dcf5" },
|
||||
{ from: "#e3f2fb", to: "#9bcef1" },
|
||||
{ from: "#ebf6fb", to: "#add8e9" },
|
||||
{ from: "#edf2fc", to: "#b7cdef" },
|
||||
{ from: "#e9f7f5", to: "#afe0dc" },
|
||||
] as const;
|
||||
|
||||
const AGENT_COLORS_LIGHTYELLOW = [
|
||||
{ from: "#fbfdef", to: "#e4efbf" },
|
||||
{ from: "#f7fae9", to: "#F3FBD9" },
|
||||
{ from: "#f9fbe8", to: "#dcebad" },
|
||||
{ from: "#faf8e9", to: "#e7dfb9" },
|
||||
{ from: "#f1f8e5", to: "#cce0af" },
|
||||
] as const;
|
||||
|
||||
type AgentAvatarTheme =
|
||||
| "dark"
|
||||
| "iceblue"
|
||||
| "lightyellow"
|
||||
| "light"
|
||||
| "lightblue"
|
||||
| "lightgreen"
|
||||
| `custom-theme-${string}`;
|
||||
|
||||
/** Returns a stable avatar gradient and readable icon color for each theme. */
|
||||
export function agentColor(id: string, theme: AgentAvatarTheme) {
|
||||
if (theme.startsWith("custom-theme-")) {
|
||||
return {
|
||||
from: "var(--accent)",
|
||||
to: "var(--brand-200)",
|
||||
foreground: "var(--accent-foreground)",
|
||||
};
|
||||
}
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
|
||||
return palette[hash % palette.length]!;
|
||||
const palette =
|
||||
theme === "dark"
|
||||
? AGENT_COLORS_DARK
|
||||
: theme === "iceblue"
|
||||
? AGENT_COLORS_ICEBLUE
|
||||
: theme === "lightyellow"
|
||||
? AGENT_COLORS_LIGHTYELLOW
|
||||
: AGENT_COLORS_LIGHT;
|
||||
return {
|
||||
...palette[hash % palette.length]!,
|
||||
foreground:
|
||||
theme === "iceblue"
|
||||
? "#2d587a"
|
||||
: theme === "lightyellow"
|
||||
? "#4c6330"
|
||||
: "#ffffff",
|
||||
};
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
||||
{agents.map((agent) => {
|
||||
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
||||
const isActive = agent.id === activeAgentId;
|
||||
const { from, to } = agentColor(agent.id, isDark);
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<Link
|
||||
key={agent.id}
|
||||
@@ -279,6 +279,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
||||
color: foreground,
|
||||
}}
|
||||
>
|
||||
<Bot className="size-[19px]" />
|
||||
|
||||
@@ -206,7 +206,7 @@ export function AgentModuleSidebar({
|
||||
const renderAgentCard = (agent: Agent, keyPrefix: string) => {
|
||||
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
||||
const isActive = agent.id === activeAgentId;
|
||||
const { from, to } = agentColor(agent.id, isDark);
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<Link
|
||||
key={`${keyPrefix}:${agent.id}`}
|
||||
@@ -236,7 +236,10 @@ export function AgentModuleSidebar({
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
||||
color: foreground,
|
||||
}}
|
||||
>
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
|
||||
@@ -93,43 +93,47 @@ export function AgentSelector({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{iconMode ? (
|
||||
<Tooltip content={iconTooltipLabel}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
{iconMode ? (
|
||||
<Tooltip content={iconTooltipLabel}>
|
||||
<div>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -167,8 +171,8 @@ export function AgentSelector({
|
||||
</span>
|
||||
)}
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
|
||||
@@ -45,11 +45,32 @@ function skillLabel(skill: Skill) {
|
||||
|
||||
function matchesQuery(skill: Skill, q: string) {
|
||||
if (!q) return true;
|
||||
return (
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
const haystack = [
|
||||
skill.name,
|
||||
skill.name_zh,
|
||||
skill.description,
|
||||
skill.detail,
|
||||
skill.category,
|
||||
skill.license,
|
||||
skill.source,
|
||||
skill.state,
|
||||
skill.owner_username,
|
||||
skill.created_at,
|
||||
skill.last_activity_at,
|
||||
skill.use_count != null ? `使用 ${skill.use_count}` : null,
|
||||
skill.view_count != null ? `查看 ${skill.view_count}` : null,
|
||||
skill.patch_count != null ? `变迁 ${skill.patch_count}` : null,
|
||||
...(skill.tags ?? []).flatMap((tag) => [
|
||||
tag.name,
|
||||
tag.description,
|
||||
tag.color,
|
||||
tag.usage_count != null ? `标签使用 ${tag.usage_count}` : null,
|
||||
]),
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
export function SkillTransferSelector({
|
||||
@@ -159,7 +180,7 @@ export function SkillTransferSelector({
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索全部技能(中英文名称、描述)…"
|
||||
placeholder="搜索技能名称、描述、分类、标签、变迁..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
@@ -239,7 +260,7 @@ export function SkillTransferSelector({
|
||||
{/* Two-column transfer panes */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SkillPane
|
||||
title="可用技能"
|
||||
title="未勾选技能"
|
||||
count={availableSkills.length}
|
||||
items={availableView}
|
||||
variant="available"
|
||||
@@ -248,10 +269,10 @@ export function SkillTransferSelector({
|
||||
onPageChange={setAvailablePage}
|
||||
onPick={add}
|
||||
onViewDetail={onViewDetail}
|
||||
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
|
||||
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部勾选"}
|
||||
/>
|
||||
<SkillPane
|
||||
title="已启用技能"
|
||||
title="已勾选技能"
|
||||
count={selectedSkills.length}
|
||||
items={selectedView}
|
||||
variant="selected"
|
||||
@@ -260,7 +281,7 @@ export function SkillTransferSelector({
|
||||
onPageChange={setSelectedPage}
|
||||
onPick={remove}
|
||||
onViewDetail={onViewDetail}
|
||||
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
|
||||
emptyHint={query ? "没有匹配的已选技能" : "尚未勾选任何技能"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
@@ -24,12 +26,15 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
|
||||
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
|
||||
*/
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "banner-custom" | "content";
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
|
||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightgreen", label: "绿色", color: "#07c05f" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "iceblue", label: "冰川蓝", color: "#9BCEF1" },
|
||||
{ id: "lightyellow", label: "浅黄色", color: "#F3FBD9" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
@@ -38,6 +43,8 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: tone === "banner-light"
|
||||
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
|
||||
: tone === "banner-custom"
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
@@ -79,8 +86,23 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
|
||||
|
||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const swatches = useMemo(
|
||||
() => [
|
||||
...THEME_SWATCHES,
|
||||
...(appearance?.custom_themes ?? [])
|
||||
.filter((preset) => preset.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((preset) => ({
|
||||
id: preset.id as ThemeType,
|
||||
label: preset.name,
|
||||
color: preset.primary,
|
||||
})),
|
||||
],
|
||||
[appearance?.custom_themes],
|
||||
);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
@@ -95,6 +117,8 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
@@ -117,9 +141,9 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
|
||||
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
|
||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
|
||||
{THEME_SWATCHES.map((swatch) => {
|
||||
<div className="relative flex max-w-64 flex-wrap items-center justify-center gap-2 rounded-lg border border-border bg-popover px-2.5 py-1.5 shadow-lg">
|
||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-border bg-popover" />
|
||||
{swatches.map((swatch) => {
|
||||
const active = theme === swatch.id;
|
||||
return (
|
||||
<button
|
||||
@@ -185,6 +209,8 @@ export function LayoutModeSwitcher({
|
||||
const current = mode ?? fallback;
|
||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -123,13 +123,14 @@ export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
hideFileName = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
@@ -142,9 +143,10 @@ export function ArtifactFileDetail({
|
||||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||||
* 默认 false,沿用全局脱敏。
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
}) {
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
hideFileName?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
@@ -222,10 +224,11 @@ export function ArtifactFileDetail({
|
||||
const canUseAiEditor = canEditArtifact;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isAutoPreviewLanguage = language === "html" || language === "markdown";
|
||||
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
if (isSpreadsheetFile) return "XLSX";
|
||||
@@ -264,29 +267,36 @@ export function ArtifactFileDetail({
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
return;
|
||||
}
|
||||
|
||||
// md/mermaid 默认预览(AI 写作视图作为可选切换项,由用户手动进入)。
|
||||
if (isSupportPreview) {
|
||||
setViewMode("preview");
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowAutoViewSwitch || language !== "html") {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setShowPreviewSwitchPulse(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreviewStreaming) {
|
||||
setViewMode("code");
|
||||
return;
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSupportPreview) {
|
||||
setViewMode("preview");
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (thread.isLoading) {
|
||||
setViewMode("code");
|
||||
@@ -298,9 +308,9 @@ export function ArtifactFileDetail({
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowPreviewSwitchPulse(false);
|
||||
}, 1400);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [allowAutoViewSwitch, language, thread.isLoading]);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
|
||||
|
||||
const scrollArtifactToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "auto") => {
|
||||
@@ -583,7 +593,8 @@ export function ArtifactFileDetail({
|
||||
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
|
||||
{fileTypeLabel}
|
||||
</span>
|
||||
<ArtifactTitle className="min-w-0">
|
||||
{!hideFileName && (
|
||||
<ArtifactTitle className="min-w-0">
|
||||
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
|
||||
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
||||
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
|
||||
@@ -606,7 +617,8 @@ export function ArtifactFileDetail({
|
||||
) : (
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
)}
|
||||
</ArtifactTitle>
|
||||
</ArtifactTitle>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||||
@@ -652,10 +664,10 @@ export function ArtifactFileDetail({
|
||||
<EyeIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{isHtmlStreaming && (
|
||||
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
|
||||
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
|
||||
正在生成,完成后自动预览
|
||||
{isPreviewStreaming && (
|
||||
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
|
||||
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
|
||||
正在生成,完成后自动预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,28 +23,34 @@ import { useThread } from "../messages/context";
|
||||
import { useArtifacts } from "./context";
|
||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||
|
||||
export function ArtifactFileList({
|
||||
className,
|
||||
files,
|
||||
threadId,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
threadId: string;
|
||||
}) {
|
||||
export function ArtifactFileList({
|
||||
className,
|
||||
files,
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
threadId: string;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installFile, setInstallFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(filepath: string) => {
|
||||
selectArtifact(filepath);
|
||||
setOpen(true);
|
||||
},
|
||||
[selectArtifact, setOpen],
|
||||
);
|
||||
const handleClick = useCallback(
|
||||
(filepath: string) => {
|
||||
if (onOpenArtifact) {
|
||||
onOpenArtifact(filepath);
|
||||
return;
|
||||
}
|
||||
selectArtifact(filepath);
|
||||
setOpen(true);
|
||||
},
|
||||
[onOpenArtifact, selectArtifact, setOpen],
|
||||
);
|
||||
|
||||
const handleInstallSkill = useCallback(
|
||||
(e: React.MouseEvent, filepath: string) => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getWelcomeBrandAsset } from "@/core/branding/brand-assets";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||
|
||||
/**
|
||||
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
|
||||
@@ -8,6 +14,15 @@ import { cn } from "@/lib/utils";
|
||||
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
|
||||
*/
|
||||
export function CompactWelcome({ className }: { className?: string }) {
|
||||
const desensitizeText = useDisplayDesensitizer();
|
||||
const { theme } = useTheme();
|
||||
const brandTheme = theme;
|
||||
const welcomeLogo = getWelcomeBrandAsset(brandTheme);
|
||||
const [failedWelcomeLogo, setFailedWelcomeLogo] = useState<string | null>(null);
|
||||
const shouldShowWelcomeLogo =
|
||||
Boolean(welcomeLogo) && failedWelcomeLogo !== welcomeLogo;
|
||||
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -16,6 +31,8 @@ export function CompactWelcome({ className }: { className?: string }) {
|
||||
)}
|
||||
>
|
||||
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
|
||||
{/* Controlled by VITE_WELCOME_LOGO_VISIBLE in runtime-config.js. */}
|
||||
{isWelcomeLogoVisible && (
|
||||
<div
|
||||
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
|
||||
style={{
|
||||
@@ -25,21 +42,32 @@ export function CompactWelcome({ className }: { className?: string }) {
|
||||
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl font-black tracking-wide text-white italic">
|
||||
CM
|
||||
</span>
|
||||
</div>
|
||||
{shouldShowWelcomeLogo && welcomeLogo ? (
|
||||
<img
|
||||
src={welcomeLogo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-16 object-contain"
|
||||
onError={() => setFailedWelcomeLogo(welcomeLogo)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl font-black tracking-wide text-white italic">
|
||||
{desensitizeText("CM")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
|
||||
欢迎使用CM助手
|
||||
{desensitizeText("欢迎使用CM助手")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
@@ -185,15 +186,24 @@ export function GeneralQaSidebar() {
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
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);
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`);
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[navigate],
|
||||
[currentSearch, navigate],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
@@ -241,7 +251,7 @@ export function GeneralQaSidebar() {
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
event.preventDefault();
|
||||
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
|
||||
navigate(newChatHref, { replace: true, state: { resetAt: Date.now() } });
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -257,10 +267,17 @@ export function GeneralQaSidebar() {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
||||
}
|
||||
},
|
||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
[
|
||||
agentIdFromPath,
|
||||
currentSearch,
|
||||
deleteThread,
|
||||
router,
|
||||
threadIdFromPath,
|
||||
threads,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
|
||||
@@ -314,11 +331,11 @@ export function GeneralQaSidebar() {
|
||||
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
|
||||
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
|
||||
<Link
|
||||
href={NEW_CHAT_PATH}
|
||||
href={newChatHref}
|
||||
onClick={handleNewChatClick}
|
||||
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
||||
className={cn(
|
||||
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
||||
"gq-new-chat-button flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
||||
"border border-solid",
|
||||
"bg-[rgb(var(--legacy-primary-rgb)/14%)] hover:bg-[rgb(var(--legacy-primary-rgb)/24%)]",
|
||||
)}
|
||||
@@ -342,7 +359,7 @@ export function GeneralQaSidebar() {
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{frequent.map((agent) => {
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const { from, to } = agentColor(agent.id, isDark);
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
@@ -366,7 +383,10 @@ export function GeneralQaSidebar() {
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
||||
color: foreground,
|
||||
}}
|
||||
>
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
@@ -412,7 +432,7 @@ export function GeneralQaSidebar() {
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href={MORE_AGENTS_HREF}
|
||||
href={moreAgentsHref}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
@@ -451,7 +471,10 @@ export function GeneralQaSidebar() {
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread);
|
||||
const href = buildFullChatNavigationPath(
|
||||
listPathOfThread(thread),
|
||||
currentSearch,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -464,9 +487,9 @@ export function GeneralQaSidebar() {
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -573,7 +596,7 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
{!query && (
|
||||
<Link
|
||||
href={MORE_CHATS_HREF}
|
||||
href={moreChatsHref}
|
||||
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>更多最近对话</span>
|
||||
|
||||
@@ -1172,7 +1172,7 @@ export function InputBox({
|
||||
className={cn(
|
||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
||||
compact &&
|
||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||
)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
|
||||
@@ -135,11 +135,12 @@ export function MessageListItem({
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
/**
|
||||
@@ -151,10 +152,11 @@ export function MessageListItem({
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
/**
|
||||
* Durable ask time of the turn this message belongs to (the preceding
|
||||
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
||||
@@ -162,14 +164,102 @@ export function MessageListItem({
|
||||
* wall clock at render time and appear to "drift" to now on every reload.
|
||||
*/
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
'';
|
||||
return (
|
||||
<AIElementMessage
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
'';
|
||||
const [assistantEditing, setAssistantEditing] = useState(false);
|
||||
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
|
||||
const [assistantSaving, setAssistantSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantEditing) {
|
||||
setAssistantDraft(messageMarkdown);
|
||||
}
|
||||
}, [assistantEditing, messageMarkdown]);
|
||||
|
||||
const canSaveAssistantEdit =
|
||||
!isLoading &&
|
||||
!isHuman &&
|
||||
!!onEditSave &&
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!!messageMarkdown;
|
||||
const confirmAssistantEdit = useCallback(async () => {
|
||||
const value = assistantDraft.trim();
|
||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||
return;
|
||||
}
|
||||
setAssistantSaving(true);
|
||||
try {
|
||||
await onEditSave(message.id, value);
|
||||
setAssistantEditing(false);
|
||||
} catch {
|
||||
// The page-level save callback owns user-facing error toasts. Keep the
|
||||
// editor open so the user can adjust and retry.
|
||||
} finally {
|
||||
setAssistantSaving(false);
|
||||
}
|
||||
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
|
||||
|
||||
if (!isHuman && assistantEditing) {
|
||||
return (
|
||||
<AIElementMessage
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
||||
anchorId && 'scroll-mt-20',
|
||||
className,
|
||||
)}
|
||||
from="assistant"
|
||||
>
|
||||
<AIElementMessageContent className="w-full">
|
||||
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
|
||||
<Textarea
|
||||
value={assistantDraft}
|
||||
autoFocus
|
||||
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void confirmAssistantEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setAssistantEditing(false);
|
||||
}
|
||||
}}
|
||||
className="min-h-32 resize-y text-[15px] leading-7"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={assistantSaving}
|
||||
onClick={() => setAssistantEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!assistantDraft.trim() || assistantSaving}
|
||||
onClick={() => void confirmAssistantEdit()}
|
||||
>
|
||||
{assistantSaving ? "保存中..." : t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AIElementMessageContent>
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIElementMessage
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
@@ -187,11 +277,12 @@ export function MessageListItem({
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={isLoading ? undefined : onEditSave}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
MessageContent (above the timestamp). Only assistant answers use the
|
||||
@@ -206,15 +297,28 @@ export function MessageListItem({
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
/>
|
||||
)}
|
||||
{canSaveAssistantEdit && (
|
||||
<Tooltip content="编辑消息">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setAssistantEditing(true)}
|
||||
>
|
||||
<PencilIcon size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
@@ -228,26 +332,31 @@ export function MessageListItem({
|
||||
* the question; confirming sends the edited text as a brand-new turn (the
|
||||
* original question stays in the thread).
|
||||
*/
|
||||
function HumanMessageBlock({
|
||||
text,
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
deleteButton,
|
||||
}: {
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
deleteButton?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
function HumanMessageBlock({
|
||||
messageId,
|
||||
text,
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
deleteButton,
|
||||
}: {
|
||||
messageId?: string;
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
deleteButton?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
@@ -255,14 +364,27 @@ function HumanMessageBlock({
|
||||
}
|
||||
}, [text, editing]);
|
||||
|
||||
const confirmEdit = useCallback(() => {
|
||||
const value = draft.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onEditResubmit?.(value);
|
||||
setEditing(false);
|
||||
}, [draft, onEditResubmit]);
|
||||
const confirmEdit = useCallback(async () => {
|
||||
const value = draft.trim();
|
||||
if (!value || saving) {
|
||||
return;
|
||||
}
|
||||
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onEditSave(messageId, value);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// The page-level save callback owns user-facing error toasts. Keep the
|
||||
// editor open so the user can adjust and retry.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onEditResubmit?.(value);
|
||||
setEditing(false);
|
||||
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -275,10 +397,10 @@ function HumanMessageBlock({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
void confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
@@ -286,17 +408,22 @@ function HumanMessageBlock({
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
|
||||
{t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void confirmEdit()}
|
||||
disabled={!draft.trim() || saving}
|
||||
>
|
||||
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,11 +451,11 @@ function HumanMessageBlock({
|
||||
{(text || deleteButton) && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||
{text && onEditResubmit && (
|
||||
<Tooltip content={t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
{text && (onEditResubmit || onEditSave) && (
|
||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -396,22 +523,24 @@ function MessageContent_({
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
// Streamdown splits content into per-block processors; calling the factory
|
||||
@@ -555,14 +684,16 @@ function MessageContent_({
|
||||
const canDelete =
|
||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||
return (
|
||||
<HumanMessageBlock
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
deleteButton={
|
||||
<HumanMessageBlock
|
||||
messageId={message.id}
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
deleteButton={
|
||||
canDelete ? (
|
||||
<DeleteMessageButton
|
||||
threadId={threadId}
|
||||
|
||||
@@ -97,14 +97,18 @@ export function MessageList({
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
scrollButtonClassName,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
}: {
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
messageContentOverrides,
|
||||
externallyHiddenMessageIds,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -115,15 +119,19 @@ export function MessageList({
|
||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||
scrollButtonClassName?: string;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
messageContentOverrides?: Record<string, string>;
|
||||
externallyHiddenMessageIds?: Set<string>;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
@@ -155,17 +163,33 @@ export function MessageList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allMessages = useMemo(
|
||||
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
||||
[thread.messages],
|
||||
);
|
||||
const messages = useMemo(
|
||||
() =>
|
||||
hiddenMessageIds.size === 0
|
||||
? allMessages
|
||||
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
||||
[allMessages, hiddenMessageIds],
|
||||
);
|
||||
const allMessages = useMemo(() => {
|
||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||
return thread.messages
|
||||
.filter((m) => !isHiddenFromUIMessage(m))
|
||||
.map((message) => {
|
||||
const override =
|
||||
hasOverrides && message.id
|
||||
? messageContentOverrides?.[message.id]
|
||||
: undefined;
|
||||
return override === undefined
|
||||
? message
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
? allMessages
|
||||
: allMessages.filter(
|
||||
(m) =>
|
||||
!m.id ||
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
@@ -380,10 +404,11 @@ export function MessageList({
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
/>
|
||||
@@ -488,7 +513,11 @@ export function MessageList({
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList files={files} threadId={threadId} />
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
ExternalLink,
|
||||
LayoutGridIcon,
|
||||
LayoutIcon,
|
||||
LeafIcon,
|
||||
ListIcon,
|
||||
MoonIcon,
|
||||
PanelLeftIcon,
|
||||
PaletteIcon,
|
||||
Plus,
|
||||
SnowflakeIcon,
|
||||
SunIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -18,7 +21,7 @@ import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,16 +64,17 @@ import {
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||
|
||||
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
const languageOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "en-US", label: enUS.locale.localName },
|
||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||
];
|
||||
|
||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue";
|
||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
||||
|
||||
function makeShortcutId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
@@ -174,9 +178,21 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
const globalDefaultLabel = useMemo(() => {
|
||||
const customTheme = (appearance?.custom_themes ?? []).find(
|
||||
(preset) => preset.id === globalDefault,
|
||||
);
|
||||
if (customTheme) return customTheme.name;
|
||||
if (globalDefault === "light") return "浅色";
|
||||
if (globalDefault === "dark") return "深色";
|
||||
if (globalDefault === "lightgreen") return "绿色";
|
||||
if (globalDefault === "iceblue") return "冰川蓝";
|
||||
if (globalDefault === "lightyellow") return "浅黄色";
|
||||
return "浅蓝";
|
||||
}, [appearance?.custom_themes, globalDefault]);
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
@@ -211,14 +227,24 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue",
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow",
|
||||
) => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
const label =
|
||||
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
|
||||
const label =
|
||||
value === "light"
|
||||
? "浅色"
|
||||
: value === "dark"
|
||||
? "深色"
|
||||
: value === "lightgreen"
|
||||
? "绿色"
|
||||
: value === "iceblue"
|
||||
? "冰川蓝"
|
||||
: value === "lightyellow"
|
||||
? "浅黄色"
|
||||
: "浅蓝";
|
||||
toast.success(`已将系统默认主题设置为${label}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -368,20 +394,44 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
description: t.settings.appearance.darkDescription,
|
||||
icon: MoonIcon,
|
||||
},
|
||||
{
|
||||
id: "lightblue",
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
icon: DropletIcon,
|
||||
},
|
||||
{
|
||||
id: "lightblue",
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
icon: DropletIcon,
|
||||
},
|
||||
{
|
||||
id: "lightgreen",
|
||||
label: t.settings.appearance.lightgreen,
|
||||
description: t.settings.appearance.lightgreenDescription,
|
||||
icon: LeafIcon,
|
||||
},
|
||||
{
|
||||
id: "iceblue",
|
||||
label: t.settings.appearance.iceblue,
|
||||
description: t.settings.appearance.iceblueDescription,
|
||||
icon: SnowflakeIcon,
|
||||
},
|
||||
{
|
||||
id: "lightyellow",
|
||||
label: t.settings.appearance.lightyellow,
|
||||
description: t.settings.appearance.lightyellowDescription,
|
||||
icon: SunIcon,
|
||||
},
|
||||
],
|
||||
[
|
||||
t.settings.appearance.dark,
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.lightblue,
|
||||
t.settings.appearance.lightblueDescription,
|
||||
t.settings.appearance.lightblue,
|
||||
t.settings.appearance.lightblueDescription,
|
||||
t.settings.appearance.lightgreen,
|
||||
t.settings.appearance.lightgreenDescription,
|
||||
t.settings.appearance.iceblue,
|
||||
t.settings.appearance.iceblueDescription,
|
||||
t.settings.appearance.lightyellow,
|
||||
t.settings.appearance.lightyellowDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -392,18 +442,34 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
description={t.settings.appearance.themeDescription}
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{themeOptions.map((option) => (
|
||||
<ThemePreviewCard
|
||||
{themeOptions.map((option) => (
|
||||
<ThemePreviewCard
|
||||
key={option.id}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "light" | "dark" | "lightblue"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
{(appearance?.custom_themes ?? [])
|
||||
.filter((preset) => preset.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((preset) => (
|
||||
<ThemePreviewCard
|
||||
key={preset.id}
|
||||
icon={PaletteIcon}
|
||||
label={preset.name}
|
||||
description={`自定义主题 · 主色 ${preset.primary}`}
|
||||
active={currentTheme === preset.id}
|
||||
mode={preset.id}
|
||||
previewDark={preset.mode === "dark"}
|
||||
accentColor={preset.primary}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
@@ -541,30 +607,80 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("lightblue")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<DropletIcon className="mr-1.5 size-4" />
|
||||
浅蓝
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light"
|
||||
? "浅色"
|
||||
: globalDefault === "dark"
|
||||
? "深色"
|
||||
: "浅蓝"}
|
||||
</p>
|
||||
浅蓝
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightgreen" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("lightgreen")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<LeafIcon className="mr-1.5 size-4" />
|
||||
绿色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "iceblue" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("iceblue")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<SnowflakeIcon className="mr-1.5 size-4" />
|
||||
冰川蓝
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightyellow" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("lightyellow")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<SunIcon className="mr-1.5 size-4" />
|
||||
浅黄色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("custom-theme-library")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
>
|
||||
<PaletteIcon className="mr-1.5 size-4" />
|
||||
管理自定义主题
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:{globalDefaultLabel}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div id="custom-theme-library" className="scroll-mt-4">
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="自定义主题库(管理员)"
|
||||
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
|
||||
>
|
||||
<CustomThemeLibrarySettings />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
@@ -1065,19 +1181,24 @@ function CompactShortcutSettings({
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "light" | "dark" | "lightblue";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue") => void;
|
||||
}) {
|
||||
const previewMode = mode;
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
previewDark,
|
||||
accentColor,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: ThemeType;
|
||||
previewDark?: boolean;
|
||||
accentColor?: string;
|
||||
onSelect: (mode: ThemeType) => void;
|
||||
}) {
|
||||
const previewMode = mode;
|
||||
const isDarkPreview = previewDark ?? previewMode === "dark";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1103,23 +1224,38 @@ function ThemePreviewCard({
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||
previewMode === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: previewMode === "lightblue"
|
||||
? "border-blue-200 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
isDarkPreview
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: previewMode === "lightblue"
|
||||
? "border-blue-200 bg-white text-slate-900"
|
||||
: previewMode === "lightgreen"
|
||||
? "border-emerald-200 bg-white text-emerald-950"
|
||||
: previewMode === "iceblue"
|
||||
? "border-sky-200 bg-[#F7FBFE] text-[#18324A]"
|
||||
: previewMode === "lightyellow"
|
||||
? "border-lime-200 bg-[#FCFDF5] text-[#303D23]"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
previewMode === "dark"
|
||||
? "bg-emerald-400"
|
||||
: previewMode === "lightblue"
|
||||
? "bg-blue-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
accentColor
|
||||
? ""
|
||||
: isDarkPreview
|
||||
? "bg-emerald-400"
|
||||
: previewMode === "lightblue"
|
||||
? "bg-blue-500"
|
||||
: previewMode === "lightgreen"
|
||||
? "bg-[#07c05f]"
|
||||
: previewMode === "iceblue"
|
||||
? "bg-[#9BCEF1]"
|
||||
: previewMode === "lightyellow"
|
||||
? "bg-[#F3FBD9]"
|
||||
: "bg-[#cf1322]",
|
||||
)}
|
||||
style={accentColor ? { backgroundColor: accentColor } : undefined}
|
||||
/>
|
||||
<div className="h-2 w-10 rounded-full bg-current/20" />
|
||||
<div className="h-2 w-6 rounded-full bg-current/15" />
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CheckIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
PlusIcon,
|
||||
RotateCcwIcon,
|
||||
SaveIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SunIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useState, type CSSProperties } from "react";
|
||||
import { ColorPickerPanel } from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type {
|
||||
CustomThemeColorOverrides,
|
||||
CustomThemePreset,
|
||||
} from "@/core/system-settings/api";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useUpdateAppearanceSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
CUSTOM_THEME_ADVANCED_KEYS,
|
||||
getCustomThemeAutomaticTokens,
|
||||
getCustomThemeTokens,
|
||||
normalizeHexColor,
|
||||
customThemeTextContrast,
|
||||
type CustomThemeAdvancedKey,
|
||||
} from "@/core/theme/custom-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_CUSTOM_THEMES = 12;
|
||||
const SUGGESTED_COLORS = [
|
||||
"#9BCEF1",
|
||||
"#F3FBD9",
|
||||
"#7AB8F5",
|
||||
"#8ED1B2",
|
||||
"#C6B4F6",
|
||||
"#F3C5A6",
|
||||
"#F3B1B7",
|
||||
"#63C4C8",
|
||||
];
|
||||
const TEXT_COLORS = ["#202A3A", "#172033", "#2D587A", "#4C6330", "#F4F7FB", "#FFFFFF"];
|
||||
|
||||
const ADVANCED_COLOR_FIELDS: Array<{
|
||||
key: CustomThemeAdvancedKey;
|
||||
label: string;
|
||||
description: string;
|
||||
token: string;
|
||||
}> = [
|
||||
{
|
||||
key: "button_hover",
|
||||
label: "按钮悬停色",
|
||||
description: "主按钮悬停时的背景色",
|
||||
token: "--theme-button-hover",
|
||||
},
|
||||
{
|
||||
key: "navigation_gradient_start",
|
||||
label: "导航渐变起点",
|
||||
description: "全量模式顶部导航栏左侧的渐变色",
|
||||
token: "--navigation-gradient-start",
|
||||
},
|
||||
{
|
||||
key: "navigation_gradient_end",
|
||||
label: "导航渐变终点",
|
||||
description: "全量模式顶部导航栏右侧的渐变色",
|
||||
token: "--navigation-gradient-end",
|
||||
},
|
||||
{
|
||||
key: "selected_background",
|
||||
label: "选中背景",
|
||||
description: "左侧菜单、卡片选中时的浅背景",
|
||||
token: "--theme-selected-background",
|
||||
},
|
||||
{
|
||||
key: "border",
|
||||
label: "边框色",
|
||||
description: "卡片、输入框和分隔线",
|
||||
token: "--border",
|
||||
},
|
||||
{
|
||||
key: "muted",
|
||||
label: "弱化背景",
|
||||
description: "次级区域与弱化容器",
|
||||
token: "--muted",
|
||||
},
|
||||
{
|
||||
key: "muted_text",
|
||||
label: "辅助文字",
|
||||
description: "说明、占位和次要信息文字",
|
||||
token: "--muted-foreground",
|
||||
},
|
||||
{
|
||||
key: "destructive",
|
||||
label: "删除/危险色",
|
||||
description: "删除按钮、错误与危险操作的颜色",
|
||||
token: "--destructive",
|
||||
},
|
||||
{
|
||||
key: "brand_title",
|
||||
label: "栏目标题文字",
|
||||
description: "业务链、分析记录等带图标栏目标题",
|
||||
token: "--brand-title-foreground",
|
||||
},
|
||||
{
|
||||
key: "primary_foreground",
|
||||
label: "主色块文字(Logo/状态标签)",
|
||||
description: "Logo 的 C、启用状态和默认标签;不影响主按钮文字",
|
||||
token: "--primary-foreground",
|
||||
},
|
||||
];
|
||||
|
||||
function makeThemeId(): `custom-theme-${string}` {
|
||||
const suffix =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
return `custom-theme-${suffix}`;
|
||||
}
|
||||
|
||||
function newCustomTheme(order: number): CustomThemePreset {
|
||||
return {
|
||||
id: makeThemeId(),
|
||||
name: `自定义主题 ${order + 1}`,
|
||||
primary: "#9BCEF1",
|
||||
text_color: null,
|
||||
mode: "light",
|
||||
overrides: {},
|
||||
enabled: true,
|
||||
sort_order: order,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeColorOverrides(
|
||||
overrides: CustomThemeColorOverrides | undefined,
|
||||
): CustomThemeColorOverrides {
|
||||
const normalized: CustomThemeColorOverrides = {};
|
||||
for (const key of CUSTOM_THEME_ADVANCED_KEYS) {
|
||||
const value = overrides?.[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
normalized[key] = normalizeHexColor(value);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeThemeDrafts(
|
||||
themes: CustomThemePreset[] | undefined,
|
||||
): CustomThemePreset[] {
|
||||
return [...(themes ?? [])]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((theme, index) => ({
|
||||
...theme,
|
||||
id: theme.id || makeThemeId(),
|
||||
name: theme.name || `自定义主题 ${index + 1}`,
|
||||
primary: normalizeHexColor(theme.primary),
|
||||
text_color:
|
||||
typeof theme.text_color === "string" && theme.text_color.trim()
|
||||
? normalizeHexColor(theme.text_color)
|
||||
: null,
|
||||
mode: theme.mode === "dark" ? "dark" : "light",
|
||||
overrides: normalizeColorOverrides(theme.overrides),
|
||||
enabled: theme.enabled !== false,
|
||||
sort_order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the TDesign panel inside the settings dialog instead of its default
|
||||
* body-level popup. The dialog otherwise treats clicks inside that portal as
|
||||
* outside interactions and closes the picker while a colour is being chosen.
|
||||
*/
|
||||
function InlineThemeColorPicker({
|
||||
value,
|
||||
fallback,
|
||||
swatchColors,
|
||||
onCommit,
|
||||
ariaLabel = "选择颜色",
|
||||
}: {
|
||||
value: string;
|
||||
fallback?: string;
|
||||
swatchColors: string[];
|
||||
onCommit: (value: string) => void;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const normalizedValue = normalizeHexColor(value, fallback);
|
||||
const [pendingValue, setPendingValue] = useState(normalizedValue);
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setPendingValue(normalizedValue);
|
||||
}, [normalizedValue, open]);
|
||||
|
||||
return (
|
||||
<div className="w-fit">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 min-w-32 items-center gap-2 rounded-md border bg-background px-2 font-mono text-sm shadow-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((visible) => !visible)}
|
||||
>
|
||||
<span
|
||||
className="size-7 rounded border"
|
||||
style={{ backgroundColor: pendingValue }}
|
||||
aria-hidden
|
||||
/>
|
||||
{pendingValue}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={panelId}
|
||||
className="mt-2 w-fit rounded-lg border bg-background p-2 shadow-lg"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ColorPickerPanel
|
||||
value={pendingValue}
|
||||
format="HEX"
|
||||
colorModes={["monochrome"]}
|
||||
enableAlpha={false}
|
||||
swatchColors={swatchColors}
|
||||
onChange={(color) => setPendingValue(normalizeHexColor(String(color), fallback))}
|
||||
/>
|
||||
<div className="mt-2 flex justify-end gap-2 border-t pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPendingValue(normalizedValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onCommit(pendingValue);
|
||||
}}
|
||||
>
|
||||
应用颜色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
|
||||
const tokens = getCustomThemeTokens(theme) as CSSProperties;
|
||||
const buttonContrast = customThemeTextContrast(theme);
|
||||
const isButtonContrastLow = buttonContrast < 4.5;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={tokens}
|
||||
aria-label={`${theme.name} 的颜色预览`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(105deg, var(--navigation-gradient-start) 0%, var(--primary) 52%, var(--navigation-gradient-end) 100%)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded-md bg-white/25 text-[10px] font-bold text-[var(--navigation-foreground)] ring-2 ring-white/35"
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[var(--navigation-foreground)]">
|
||||
实时预览
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-[11px]",
|
||||
isButtonContrastLow
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: "text-[var(--muted-foreground)]",
|
||||
)}
|
||||
>
|
||||
主按钮对比度 {buttonContrast.toFixed(1)}:1
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2 bg-[var(--background)] p-3 sm:grid-cols-[1fr_auto]">
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--card)] p-2.5">
|
||||
<div className="text-xs font-semibold text-[var(--brand-title-foreground)]">
|
||||
{theme.name || "未命名主题"}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--muted-foreground)]">
|
||||
页面正文自动生成;主按钮文字可单独指定。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-[var(--primary)] px-3 py-2 text-xs font-semibold text-[var(--button-foreground,var(--primary-foreground))] shadow-sm"
|
||||
tabIndex={-1}
|
||||
>
|
||||
主按钮
|
||||
</button>
|
||||
{isButtonContrastLow && (
|
||||
<p className="sm:col-span-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
当前按钮文字会按您的颜色设置展示,但对比度低于 4.5:1,建议改用更深的主色或恢复自动。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedThemeColorControls({
|
||||
theme,
|
||||
onPatch,
|
||||
}: {
|
||||
theme: CustomThemePreset;
|
||||
onPatch: (patch: Partial<CustomThemePreset>) => void;
|
||||
}) {
|
||||
const automaticTokens = getCustomThemeAutomaticTokens({
|
||||
primary: theme.primary,
|
||||
mode: theme.mode,
|
||||
});
|
||||
|
||||
const setOverride = (key: CustomThemeAdvancedKey, value: string) => {
|
||||
const overrides = { ...(theme.overrides ?? {}) };
|
||||
if (!value.trim()) {
|
||||
delete overrides[key];
|
||||
} else {
|
||||
overrides[key] = value;
|
||||
}
|
||||
onPatch({ overrides });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-dashed bg-muted/15 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">更多配色</div>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
默认由主色自动推导。仅在品牌规范有明确要求时,选择单项覆盖;“恢复自动”会重新跟随主色。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
已覆盖 {Object.keys(theme.overrides ?? {}).length} 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{ADVANCED_COLOR_FIELDS.map((field) => {
|
||||
const explicit = theme.overrides?.[field.key];
|
||||
const automatic = automaticTokens[field.token] ?? "#0062D9";
|
||||
const value = explicit ?? automatic;
|
||||
return (
|
||||
<div key={field.key} className="rounded-md border bg-background p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold">{field.label}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{field.description}
|
||||
</div>
|
||||
</div>
|
||||
{explicit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setOverride(field.key, "")}
|
||||
>
|
||||
<RotateCcwIcon className="mr-1 size-3.5" />
|
||||
恢复自动
|
||||
</Button>
|
||||
) : (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
自动
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<InlineThemeColorPicker
|
||||
value={value}
|
||||
swatchColors={SUGGESTED_COLORS}
|
||||
onCommit={(color) => setOverride(field.key, color)}
|
||||
ariaLabel={`${theme.name || "自定义主题"}${field.label}选色器`}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
maxLength={7}
|
||||
className="h-8 w-28 font-mono text-xs uppercase"
|
||||
onChange={(event) => setOverride(field.key, event.target.value)}
|
||||
onBlur={(event) =>
|
||||
setOverride(
|
||||
field.key,
|
||||
event.target.value.trim()
|
||||
? normalizeHexColor(event.target.value)
|
||||
: "",
|
||||
)
|
||||
}
|
||||
aria-label={`${theme.name || "自定义主题"}${field.label}十六进制值`}
|
||||
/>
|
||||
</div>
|
||||
{!explicit && (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
当前自动值:{automatic}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomThemeLibrarySettings() {
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const [drafts, setDrafts] = useState<CustomThemePreset[]>([]);
|
||||
const [advancedThemeIds, setAdvancedThemeIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDrafts(normalizeThemeDrafts(appearance?.custom_themes));
|
||||
}, [appearance?.custom_themes]);
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() =>
|
||||
JSON.stringify(normalizeThemeDrafts(appearance?.custom_themes)) !==
|
||||
JSON.stringify(normalizeThemeDrafts(drafts)),
|
||||
[appearance?.custom_themes, drafts],
|
||||
);
|
||||
|
||||
const patchTheme = (id: string, patch: Partial<CustomThemePreset>) => {
|
||||
setDrafts((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAdvancedTheme = (id: string) => {
|
||||
setAdvancedThemeIds((ids) => {
|
||||
const next = new Set(ids);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const cleaned = normalizeThemeDrafts(drafts).map((theme) => ({
|
||||
...theme,
|
||||
name: theme.name.trim(),
|
||||
primary: normalizeHexColor(theme.primary),
|
||||
}));
|
||||
if (cleaned.some((theme) => !theme.name)) {
|
||||
toast.error("请为每个主题填写名称");
|
||||
return;
|
||||
}
|
||||
const names = cleaned.map((theme) => theme.name.toLocaleLowerCase());
|
||||
if (new Set(names).size !== names.length) {
|
||||
toast.error("主题名称不能重复");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ custom_themes: cleaned });
|
||||
toast.success("自定义主题库已保存,所有用户可在主题切换器中选择已启用主题");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存主题库失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (theme: CustomThemePreset) => {
|
||||
if (!theme.enabled || appearance?.default_theme === theme.id) return;
|
||||
if (!(appearance?.custom_themes ?? []).some((preset) => preset.id === theme.id)) {
|
||||
toast.info("请先保存主题库,再将新主题设为系统默认");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: theme.id });
|
||||
toast.success(`已将“${theme.name || "自定义主题"}”设为系统默认主题`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "设置系统默认主题失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
|
||||
默认只需设置主色、主按钮文字与明暗模式;系统会自动生成页面正文、背景、边框、弱化、选中和悬停色。点击“更多”后才按需覆盖个别交互色,停用后不再向用户展示,最多可保存 {MAX_CUSTOM_THEMES} 个。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDrafts((items) =>
|
||||
items.length >= MAX_CUSTOM_THEMES
|
||||
? items
|
||||
: [...items, newCustomTheme(items.length)],
|
||||
)
|
||||
}
|
||||
disabled={drafts.length >= MAX_CUSTOM_THEMES || updateAppearance.isPending}
|
||||
>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
添加主题
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!hasChanges || updateAppearance.isPending}
|
||||
>
|
||||
<SaveIcon className="mr-1.5 size-4" />
|
||||
保存主题库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-5 py-8 text-center">
|
||||
<PaletteIcon className="mx-auto mb-2 size-5 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">还没有自定义主题</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
添加后,用户可以从右上角主题切换器中自行选择。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{drafts.map((theme, index) => {
|
||||
const isDefault = appearance?.default_theme === theme.id;
|
||||
const advancedOpen = advancedThemeIds.has(theme.id);
|
||||
const automaticTokens = getCustomThemeAutomaticTokens({
|
||||
primary: theme.primary,
|
||||
mode: theme.mode,
|
||||
});
|
||||
const textColor = theme.text_color ?? automaticTokens["--foreground"] ?? "#202A3A";
|
||||
return (
|
||||
<div key={theme.id} className="space-y-4 rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PaletteIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">主题 {index + 1}</span>
|
||||
{isDefault && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<CheckIcon className="size-3" />
|
||||
系统默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Switch
|
||||
checked={theme.enabled}
|
||||
onCheckedChange={(enabled) => patchTheme(theme.id, { enabled })}
|
||||
aria-label={`启用${theme.name || "自定义主题"}`}
|
||||
/>
|
||||
{theme.enabled ? "启用" : "停用"}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isDefault ? "default" : "outline"}
|
||||
onClick={() => void handleSetDefault(theme)}
|
||||
disabled={!theme.enabled || isDefault || hasChanges || updateAppearance.isPending}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleAdvancedTheme(theme.id)}
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls={`${theme.id}-advanced-colors`}
|
||||
>
|
||||
<SlidersHorizontalIcon className="mr-1.5 size-4" />
|
||||
更多
|
||||
{advancedOpen ? (
|
||||
<ChevronUpIcon className="ml-1 size-3.5" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-1 size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDrafts((items) =>
|
||||
items
|
||||
.filter((item) => item.id !== theme.id)
|
||||
.map((item, order) => ({ ...item, sort_order: order })),
|
||||
)
|
||||
}
|
||||
title="删除主题"
|
||||
aria-label={`删除${theme.name || "自定义主题"}`}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(180px,0.55fr)_150px_150px]">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium">主题名称</span>
|
||||
<Input
|
||||
value={theme.name}
|
||||
maxLength={32}
|
||||
onChange={(event) => patchTheme(theme.id, { name: event.target.value })}
|
||||
placeholder="例如:品牌浅蓝"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium">基础模式</span>
|
||||
<Select
|
||||
value={theme.mode}
|
||||
onValueChange={(value) =>
|
||||
patchTheme(theme.id, {
|
||||
mode: value === "dark" ? "dark" : "light",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">
|
||||
<span className="inline-flex items-center gap-1.5"><SunIcon className="size-3.5" />浅色</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<span className="inline-flex items-center gap-1.5"><MoonIcon className="size-3.5" />深色</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium">主色值</span>
|
||||
<Input
|
||||
value={theme.primary}
|
||||
maxLength={7}
|
||||
className="font-mono uppercase"
|
||||
onChange={(event) => patchTheme(theme.id, { primary: event.target.value })}
|
||||
onBlur={() =>
|
||||
patchTheme(theme.id, {
|
||||
primary: normalizeHexColor(theme.primary),
|
||||
})
|
||||
}
|
||||
aria-label={`${theme.name || "自定义主题"}主色十六进制值`}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">主按钮文字</span>
|
||||
{theme.text_color ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[11px]"
|
||||
onClick={() => patchTheme(theme.id, { text_color: null })}
|
||||
>
|
||||
自动
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground">自动</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={textColor}
|
||||
maxLength={7}
|
||||
className="font-mono uppercase"
|
||||
onChange={(event) =>
|
||||
patchTheme(theme.id, { text_color: event.target.value })
|
||||
}
|
||||
onBlur={(event) =>
|
||||
patchTheme(theme.id, {
|
||||
text_color: event.target.value.trim()
|
||||
? normalizeHexColor(event.target.value)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
aria-label={`${theme.name || "自定义主题"}主按钮文字十六进制值`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/15 p-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium">选择主色</span>
|
||||
<InlineThemeColorPicker
|
||||
value={theme.primary}
|
||||
swatchColors={SUGGESTED_COLORS}
|
||||
onCommit={(primary) => patchTheme(theme.id, { primary })}
|
||||
ariaLabel={`${theme.name || "自定义主题"}主色选色器`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium">选择主按钮文字</span>
|
||||
<InlineThemeColorPicker
|
||||
value={textColor}
|
||||
fallback="#202A3A"
|
||||
swatchColors={TEXT_COLORS}
|
||||
onCommit={(text_color) => patchTheme(theme.id, { text_color })}
|
||||
ariaLabel={`${theme.name || "自定义主题"}主按钮文字选色器`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 pb-0.5">
|
||||
{SUGGESTED_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-7 rounded-md border shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
normalizeHexColor(theme.primary) === color
|
||||
? "ring-2 ring-primary ring-offset-2"
|
||||
: "border-border",
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => patchTheme(theme.id, { primary: color })}
|
||||
title={`使用 ${color}`}
|
||||
aria-label={`使用主色 ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<div id={`${theme.id}-advanced-colors`}>
|
||||
<AdvancedThemeColorControls
|
||||
theme={theme}
|
||||
onPatch={(patch) => patchTheme(theme.id, patch)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomThemePreview theme={{ ...theme, primary: normalizeHexColor(theme.primary) }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -470,11 +470,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
@@ -497,11 +497,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{!isLoading && !error && (
|
||||
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
||||
共 {orderedSkills.length} 个技能
|
||||
</div>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AuroraText } from "../ui/aurora-text";
|
||||
@@ -15,7 +16,8 @@ export function Welcome({
|
||||
className?: string;
|
||||
mode?: "ultra" | "pro" | "thinking" | "flash";
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const desensitizeText = useDisplayDesensitizer();
|
||||
const searchParams = useSearchParams();
|
||||
const isUltra = useMemo(() => mode === "ultra", [mode]);
|
||||
const colors = useMemo(() => {
|
||||
@@ -36,7 +38,7 @@ export function Welcome({
|
||||
t.welcome.createYourOwnSkill
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
|
||||
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -23,19 +23,19 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
const NAV_ITEM_CLASS =
|
||||
"text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
|
||||
"workspace-nav-item text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
|
||||
|
||||
// 「新对话」专属:默认仅主色文字/图标(显眼但不是选中态),命中 /chats/new 时才上主色底
|
||||
const NEW_CHAT_CLASS =
|
||||
"rounded-xl h-9 px-3 text-primary transition-colors hover:bg-primary/10 hover:text-primary " +
|
||||
"workspace-nav-new-chat rounded-xl h-9 px-3 text-primary transition-colors hover:bg-primary/10 hover:text-primary " +
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
|
||||
|
||||
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
|
||||
const NEW_CHAT_COMPACT_CLASS =
|
||||
"h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
|
||||
"workspace-compact-new-chat h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
|
||||
"border border-[rgb(var(--legacy-primary-rgb)/18%)] " +
|
||||
"hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " +
|
||||
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
||||
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/position">
|
||||
<BriefcaseBusiness />
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PlugIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -219,14 +218,14 @@ export function WorkspaceNavMenu() {
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
{/* {isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/plugins")}
|
||||
>
|
||||
<PlugIcon />
|
||||
插件管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
)} */}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/positions")}
|
||||
@@ -235,6 +234,14 @@ export function WorkspaceNavMenu() {
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/position-roles")}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
岗位角色配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -20,11 +20,15 @@ import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
@@ -32,8 +36,26 @@ 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");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
|
||||
@@ -8,13 +8,20 @@ import React, {
|
||||
import { useTheme as useNextTheme } from 'next-themes';
|
||||
|
||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||
import {
|
||||
BUILTIN_THEME_IDS,
|
||||
type BuiltinThemeId,
|
||||
type CustomThemePreset,
|
||||
} from '@/core/system-settings/api';
|
||||
import { getCustomThemeTokens, isCustomThemeId } from '@/core/theme/custom-theme';
|
||||
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light' | 'lightblue';
|
||||
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
|
||||
|
||||
// 主题配置
|
||||
export interface ThemeConfig {
|
||||
type: ThemeType;
|
||||
type: BuiltinThemeId;
|
||||
name: string;
|
||||
colors: {
|
||||
background: string;
|
||||
@@ -37,7 +44,7 @@ export interface ThemeConfig {
|
||||
}
|
||||
|
||||
// 主题配置
|
||||
const themes: Record<ThemeType, ThemeConfig> = {
|
||||
const themes: Record<BuiltinThemeId, ThemeConfig> = {
|
||||
dark: {
|
||||
type: 'dark',
|
||||
name: '深色主题',
|
||||
@@ -104,6 +111,74 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
lightgreen: {
|
||||
type: 'lightgreen',
|
||||
name: '绿色主题',
|
||||
colors: {
|
||||
background: 'white',
|
||||
backgroundSecondary: 'emerald-50',
|
||||
text: 'emerald-950',
|
||||
textSecondary: 'emerald-800',
|
||||
primary: 'emerald-600',
|
||||
primaryHover: 'emerald-500',
|
||||
border: 'emerald-200',
|
||||
accent: 'emerald-700',
|
||||
success: 'emerald-600',
|
||||
warning: 'amber-500',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'emerald-50',
|
||||
input: 'white',
|
||||
inputBorder: 'emerald-200',
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
iceblue: {
|
||||
type: 'iceblue',
|
||||
name: '冰川蓝主题',
|
||||
colors: {
|
||||
background: 'sky-50',
|
||||
backgroundSecondary: 'sky-100',
|
||||
text: 'slate-800',
|
||||
textSecondary: 'slate-600',
|
||||
// The CSS --primary token is exactly #9BCEF1. Darker blues are used
|
||||
// only for readable text, hover and focus states.
|
||||
primary: 'sky-200',
|
||||
primaryHover: 'sky-300',
|
||||
border: 'sky-200',
|
||||
accent: 'sky-200',
|
||||
success: 'emerald-600',
|
||||
warning: 'amber-600',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'sky-50',
|
||||
input: 'white',
|
||||
inputBorder: 'sky-200',
|
||||
overlay: 'slate-900/30',
|
||||
},
|
||||
},
|
||||
lightyellow: {
|
||||
type: 'lightyellow',
|
||||
name: '浅黄色主题',
|
||||
colors: {
|
||||
background: 'lime-50',
|
||||
backgroundSecondary: 'lime-100',
|
||||
text: 'lime-950',
|
||||
textSecondary: 'lime-800',
|
||||
primary: 'lime-100',
|
||||
primaryHover: 'lime-200',
|
||||
border: 'lime-200',
|
||||
accent: 'lime-100',
|
||||
success: 'emerald-600',
|
||||
warning: 'amber-600',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'lime-50',
|
||||
input: 'white',
|
||||
inputBorder: 'lime-200',
|
||||
overlay: 'slate-900/30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
@@ -114,13 +189,30 @@ const STORAGE_KEY = 'strategy-theme';
|
||||
// admin changes. When the flag is present we never override the user.
|
||||
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
|
||||
|
||||
const CUSTOM_THEME_TOKEN_NAMES = Object.keys(
|
||||
getCustomThemeTokens({ primary: '#0062D9', mode: 'light' }),
|
||||
);
|
||||
|
||||
function isBuiltinTheme(value: string | null | undefined): value is BuiltinThemeId {
|
||||
return typeof value === 'string' && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function getEnabledCustomTheme(
|
||||
id: string | null | undefined,
|
||||
presets: CustomThemePreset[] | undefined,
|
||||
): CustomThemePreset | undefined {
|
||||
return isCustomThemeId(id)
|
||||
? presets?.find((preset) => preset.id === id && preset.enabled)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'dark') return 'dark';
|
||||
if (saved === 'lightblue') return 'lightblue';
|
||||
if (isBuiltinTheme(saved)) return saved;
|
||||
if (isCustomThemeId(saved)) return saved;
|
||||
return 'light';
|
||||
}
|
||||
|
||||
@@ -137,6 +229,8 @@ function markExplicitChoice(): void {
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
/** Resolved visual mode; custom themes keep their own light/dark setting. */
|
||||
baseMode: 'light' | 'dark';
|
||||
themeConfig: ThemeConfig;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: ThemeType) => void;
|
||||
@@ -161,7 +255,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// a theme yet, this value drives the rendered theme — and re-syncs on
|
||||
// every mount so an admin change propagates without users clearing
|
||||
// localStorage by hand.
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const { data: appearance } = useAppearanceSettings({
|
||||
enabled: !isPositionRoundtableStandaloneMode(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -175,12 +271,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (
|
||||
current &&
|
||||
current !== 'light' &&
|
||||
current !== 'dark' &&
|
||||
current !== 'lightblue'
|
||||
) {
|
||||
if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -211,19 +302,48 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
||||
|
||||
const effectiveTheme = forcedTheme ?? resolvedTheme;
|
||||
const theme: ThemeType = mounted
|
||||
? effectiveTheme === 'dark'
|
||||
? 'dark'
|
||||
: effectiveTheme === 'lightblue'
|
||||
? 'lightblue'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
const persistedTheme = readPersistedTheme();
|
||||
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
|
||||
const customTheme = getEnabledCustomTheme(
|
||||
requestedTheme,
|
||||
appearance?.custom_themes,
|
||||
);
|
||||
const theme: ThemeType = isBuiltinTheme(requestedTheme)
|
||||
? requestedTheme
|
||||
: customTheme?.id ?? 'light';
|
||||
const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// next-themes adds a class for every value passed to setTheme(). Custom
|
||||
// themes use data attributes and CSS variables instead, so remove its
|
||||
// transient class before applying the generated token set.
|
||||
for (const className of [...root.classList]) {
|
||||
if (isCustomThemeId(className)) {
|
||||
root.classList.remove(className);
|
||||
}
|
||||
}
|
||||
for (const tokenName of CUSTOM_THEME_TOKEN_NAMES) {
|
||||
root.style.removeProperty(tokenName);
|
||||
}
|
||||
root.removeAttribute('data-custom-theme');
|
||||
|
||||
if (customTheme) {
|
||||
for (const [tokenName, tokenValue] of Object.entries(
|
||||
getCustomThemeTokens(customTheme),
|
||||
)) {
|
||||
root.style.setProperty(tokenName, tokenValue);
|
||||
}
|
||||
root.setAttribute('data-theme', customTheme.id);
|
||||
root.setAttribute('data-custom-theme', 'true');
|
||||
root.setAttribute('theme-mode', customTheme.mode);
|
||||
root.classList.toggle('dark', customTheme.mode === 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.setAttribute('theme-mode', 'dark');
|
||||
root.classList.add('dark');
|
||||
@@ -234,12 +354,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
root.removeAttribute('theme-mode');
|
||||
root.classList.remove('dark');
|
||||
|
||||
if (theme === 'lightblue') {
|
||||
root.setAttribute('data-theme', 'lightblue');
|
||||
if (
|
||||
theme === 'lightblue' ||
|
||||
theme === 'lightgreen' ||
|
||||
theme === 'iceblue' ||
|
||||
theme === 'lightyellow'
|
||||
) {
|
||||
root.setAttribute('data-theme', theme);
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
}
|
||||
}, [theme]);
|
||||
}, [customTheme, theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
@@ -251,11 +376,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
markExplicitChoice();
|
||||
};
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
||||
value={{ theme, baseMode, themeConfig, toggleTheme, setTheme, mounted }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
@@ -277,6 +402,20 @@ export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string =
|
||||
return themeConfig.colors[colorKey];
|
||||
};
|
||||
|
||||
/** Resolve custom runtime themes to the two visual modes expected by older
|
||||
* integrations (reports, canvases and exported artifacts). */
|
||||
export function themeToBaseMode(theme: ThemeType): 'light' | 'dark' {
|
||||
if (theme === 'dark') return 'dark';
|
||||
if (
|
||||
isCustomThemeId(theme) &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement.getAttribute('theme-mode') === 'dark'
|
||||
) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
// 组合类名工具函数
|
||||
export const cn = (...classes: (string | undefined | null | false)[]): string => {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
|
||||
@@ -150,12 +150,15 @@ export function useAllAgentsAdmin(enabled: boolean) {
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useAgent(id: string | null | undefined) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
export function useAgent(
|
||||
id: string | null | undefined,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
return { agent: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,32 @@ export async function loadArtifactContent({
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
return { content: text, url };
|
||||
}
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let detail = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
|
||||
const parsedDetail =
|
||||
typeof parsed.detail === "string"
|
||||
? parsed.detail
|
||||
: typeof parsed.message === "string"
|
||||
? parsed.message
|
||||
: "";
|
||||
if (parsedDetail) {
|
||||
detail = parsedDetail;
|
||||
}
|
||||
} catch {
|
||||
// Keep the raw response text as the diagnostic detail.
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
return { content: text, url };
|
||||
}
|
||||
|
||||
export function loadArtifactContentFromToolCall({
|
||||
url: urlString,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import type { AgentThread } from "../threads";
|
||||
|
||||
export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
@@ -16,22 +16,24 @@ export function urlOfArtifact({
|
||||
}) {
|
||||
// A public read-only share has no auth-gated thread route — its referenced
|
||||
// files are served by the unauthenticated public-share endpoint instead.
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||
return thread.values.artifacts ?? [];
|
||||
}
|
||||
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
||||
}
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
/**
|
||||
* Short-link route map maintained by the frontend.
|
||||
*
|
||||
* Add a new entry here when an external system needs a short ``routeKey``.
|
||||
* Targets must remain internal SPA routes; LoginPage validates them again
|
||||
* before navigating. The optional display settings are converted into the
|
||||
* existing route parameters on the destination URL.
|
||||
*/
|
||||
export interface RouteKeyMapping {
|
||||
/** Internal SPA route to open after sign-in. */
|
||||
path: string;
|
||||
/** Add `hideChrome=1` to hide the global header and page sidebar. */
|
||||
hideChrome?: boolean;
|
||||
/** Add the existing `theme` route parameter and apply it during sign-in. */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = {
|
||||
a: { path: "/page/canvas/ai-writing" },
|
||||
"8bf": {
|
||||
path: "/page/strategy/qa/8bf-roundtable",
|
||||
hideChrome: true,
|
||||
theme: "dark-blue",
|
||||
},
|
||||
};
|
||||
|
||||
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
|
||||
export const ROUTE_KEY_PARAM = "routeKey";
|
||||
|
||||
/**
|
||||
* Optional frontend-configured username-login password.
|
||||
*
|
||||
* This is intentionally an environment/runtime value rather than a literal in
|
||||
* source code. It is used only with a mapped ``routeKey`` link. When it is
|
||||
* empty, LoginPage falls back to the URL's existing ``password`` parameter.
|
||||
*/
|
||||
export function getRouteKeyLoginPassword(): string {
|
||||
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
|
||||
}
|
||||
|
||||
export function resolveRouteKeyMapping(
|
||||
routeKey: string | null | undefined,
|
||||
): RouteKeyMapping | undefined {
|
||||
const key = routeKey?.trim();
|
||||
if (!key) return undefined;
|
||||
return ROUTE_KEY_MAPPINGS[key];
|
||||
}
|
||||
|
||||
export function resolveRouteKey(routeKey: string | null | undefined): string {
|
||||
const mapping = resolveRouteKeyMapping(routeKey);
|
||||
if (!mapping) return "";
|
||||
|
||||
const [path, ownQuery = ""] = mapping.path.split("?");
|
||||
const query = new URLSearchParams(ownQuery);
|
||||
if (mapping.hideChrome) query.set("hideChrome", "1");
|
||||
if (mapping.theme) query.set("theme", mapping.theme);
|
||||
|
||||
const serializedQuery = query.toString();
|
||||
return serializedQuery ? `${path}?${serializedQuery}` : path;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import fallbackLogoDark from "@/assets/img/brand-logo-dark.png";
|
||||
import fallbackLogoLight from "@/assets/img/brand-logo-light.png";
|
||||
import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark";
|
||||
type BrandAssetTheme = BrandTheme | `custom-theme-${string}`;
|
||||
|
||||
const headerFallbackAssets: Record<BrandTheme, string> = {
|
||||
light: fallbackLogoLight,
|
||||
lightblue: fallbackLogoLightblue,
|
||||
lightgreen: fallbackLogoLight,
|
||||
iceblue: fallbackLogoLightblue,
|
||||
lightyellow: fallbackLogoLight,
|
||||
dark: fallbackLogoDark,
|
||||
};
|
||||
|
||||
const headerRuntimeKeys: Record<BrandTheme, string> = {
|
||||
light: "VITE_BRAND_LOGO_LIGHT_URL",
|
||||
lightblue: "VITE_BRAND_LOGO_LIGHTBLUE_URL",
|
||||
lightgreen: "VITE_BRAND_LOGO_LIGHTGREEN_URL",
|
||||
iceblue: "VITE_BRAND_LOGO_ICEBLUE_URL",
|
||||
lightyellow: "VITE_BRAND_LOGO_LIGHTYELLOW_URL",
|
||||
dark: "VITE_BRAND_LOGO_DARK_URL",
|
||||
};
|
||||
|
||||
const welcomeRuntimeKeys: Record<BrandTheme, string> = {
|
||||
light: "VITE_BRAND_WELCOME_LIGHT_URL",
|
||||
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
|
||||
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
|
||||
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
|
||||
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
|
||||
dark: "VITE_BRAND_WELCOME_DARK_URL",
|
||||
};
|
||||
|
||||
function runtimeConfigBaseUrl(): string | undefined {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return document.querySelector<HTMLScriptElement>("script[data-runtime-config]")?.src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an optional post-build brand override from runtime-config.js.
|
||||
* Relative paths are anchored to runtime-config.js, which keeps the URL valid
|
||||
* when the whole SPA is mounted below an Nginx location prefix.
|
||||
*/
|
||||
function readRuntimeBrandAsset(key: string): string | undefined {
|
||||
const configuredUrl = readRuntimeEnv(key);
|
||||
if (!configuredUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl =
|
||||
runtimeConfigBaseUrl() ??
|
||||
(typeof window !== "undefined" ? window.location.href : undefined);
|
||||
return baseUrl ? new URL(configuredUrl, baseUrl).href : configuredUrl;
|
||||
} catch {
|
||||
return configuredUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme {
|
||||
if (!theme.startsWith("custom-theme-")) return theme as BrandTheme;
|
||||
return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark"
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
|
||||
const resolved = resolveBrandTheme(theme);
|
||||
return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved];
|
||||
}
|
||||
|
||||
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
|
||||
return headerFallbackAssets[resolveBrandTheme(theme)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome content deliberately has no bundled image fallback. An empty
|
||||
* runtime URL keeps the original CM text + gradient treatment.
|
||||
*/
|
||||
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
|
||||
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
|
||||
export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
|
||||
/**
|
||||
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
|
||||
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
|
||||
@@ -39,6 +43,8 @@ export interface EmbedSession {
|
||||
theme: EmbedTheme;
|
||||
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
|
||||
hideJump: boolean;
|
||||
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
|
||||
hideHeader: boolean;
|
||||
/**
|
||||
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
|
||||
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
|
||||
@@ -75,6 +81,11 @@ export function readHideJumpParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideJump");
|
||||
}
|
||||
|
||||
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
|
||||
export function readHideHeaderParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideHeader");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
|
||||
export function readHideChromeParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideChrome");
|
||||
@@ -85,16 +96,6 @@ export function readIsolateParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "isolate");
|
||||
}
|
||||
|
||||
/**
|
||||
* 把外部传入的主题色映射成离散明/暗。与 `resolveThemeFromQuery` 同源约定(dark-blue → 深色),
|
||||
* 另外宽松接受 `dark` / 中文「深色/深蓝」直传;其余(含空)一律浅色。
|
||||
*/
|
||||
export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme {
|
||||
const v = (raw ?? "").trim().toLowerCase();
|
||||
if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark";
|
||||
return "light";
|
||||
}
|
||||
|
||||
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
|
||||
export function readEmbedThemeParam(search: string): string {
|
||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||
@@ -110,6 +111,7 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
embed: p.embed === true,
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
};
|
||||
@@ -121,7 +123,7 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||
function writeEmbedSession(next: EmbedSession): void {
|
||||
try {
|
||||
if (!next.embed && !next.hideJump && !next.hideChrome && !next.isolate) {
|
||||
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
|
||||
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
@@ -141,6 +143,16 @@ export function isJumpBarHidden(): boolean {
|
||||
return readEmbedSession()?.hideJump === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tab should hide its global Header.
|
||||
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
|
||||
* deliberately leaves PageSidebar visible.
|
||||
*/
|
||||
export function isHeaderHidden(): boolean {
|
||||
const session = readEmbedSession();
|
||||
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
|
||||
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
|
||||
@@ -151,6 +163,10 @@ export function isChromeHidden(): boolean {
|
||||
return s?.embed === true || s?.hideChrome === true;
|
||||
}
|
||||
|
||||
export function isHideChromeActive(): boolean {
|
||||
return readEmbedSession()?.hideChrome === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
|
||||
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
|
||||
@@ -168,7 +184,14 @@ export function isIsolatedSession(): boolean {
|
||||
export function syncEmbedSessionFromSearch(search: string): void {
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
|
||||
{
|
||||
embed: false,
|
||||
theme: "light" as EmbedTheme,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
};
|
||||
const next: EmbedSession = { ...cur };
|
||||
|
||||
const embedParam = readEmbedParam(search);
|
||||
@@ -183,6 +206,10 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
if (hideJumpParam === "on") next.hideJump = true;
|
||||
else if (hideJumpParam === "off") next.hideJump = false;
|
||||
|
||||
const hideHeaderParam = readHideHeaderParam(search);
|
||||
if (hideHeaderParam === "on") next.hideHeader = true;
|
||||
else if (hideHeaderParam === "off") next.hideHeader = false;
|
||||
|
||||
const hideChromeParam = readHideChromeParam(search);
|
||||
if (hideChromeParam === "on") next.hideChrome = true;
|
||||
else if (hideChromeParam === "off") next.hideChrome = false;
|
||||
@@ -234,6 +261,13 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
|
||||
export function setIsolatedSession(on: boolean): void {
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
|
||||
{
|
||||
embed: false,
|
||||
theme: "light" as EmbedTheme,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
};
|
||||
writeEmbedSession({ ...cur, isolate: on });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Whether the application is currently hosted by another page.
|
||||
*
|
||||
* This intentionally covers both a native `<iframe>` and Wujie's child-app
|
||||
* runtime. Accessing `window.top` is safe across origins for identity
|
||||
* comparison, but the guard keeps the login/route bootstrap resilient to
|
||||
* restrictive browser hosts.
|
||||
*/
|
||||
export function isEmbeddedHost(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const hostWindow = window as Window & {
|
||||
__POWERED_BY_WUJIE__?: boolean;
|
||||
};
|
||||
if (hostWindow.__POWERED_BY_WUJIE__) return true;
|
||||
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
// Some sandboxed or cross-origin hosts deny top-window access. They are
|
||||
// still embedded contexts and should use the same safe entry route.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @typedef {"light" | "dark"} EmbedThemeMode
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map external theme names to the two themes supported by iframe surfaces.
|
||||
*
|
||||
* @param {string | null | undefined} rawTheme
|
||||
* @returns {EmbedThemeMode}
|
||||
*/
|
||||
export function resolveEmbedThemeMode(rawTheme) {
|
||||
const theme = (rawTheme ?? "").trim().toLowerCase();
|
||||
return theme === "dark" ||
|
||||
theme === "dark-blue" ||
|
||||
theme === "深色" ||
|
||||
theme === "深蓝"
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
@@ -448,11 +448,17 @@ export const enUS: Translations = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
iceblue: "Glacier Blue",
|
||||
lightyellow: "Pale Yellow",
|
||||
systemDescription: "Match the operating system preference automatically.",
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
languageTitle: "Language",
|
||||
languageDescription: "Switch between languages.",
|
||||
},
|
||||
|
||||
@@ -383,11 +383,17 @@ export interface Translations {
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
lightyellow: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
lightyellowDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
|
||||
@@ -429,11 +429,17 @@ export const zhCN: Translations = {
|
||||
system: "系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
lightyellow: "浅黄色",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
|
||||
@@ -2,8 +2,19 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildStandardChatRedirectPath,
|
||||
buildFullChatNavigationPath,
|
||||
buildEmbeddedIframeRedirectPath,
|
||||
buildChatsIframeRedirectPath,
|
||||
buildChatsIframeThreadPath,
|
||||
hasIframePresentationOverride,
|
||||
isExplicitBrainyIframeMode,
|
||||
isChatsIframeWorkspacePath,
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolvePostLoginRedirectPath,
|
||||
resolveForcedThemeForPath,
|
||||
resolveStandardChatFallback,
|
||||
shouldUseChatsIframeProtocol,
|
||||
} from "./chats-iframe-route.ts";
|
||||
|
||||
test("matches only workspace chats_iframe routes", () => {
|
||||
@@ -31,3 +42,277 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
);
|
||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||
});
|
||||
|
||||
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
|
||||
assert.equal(isExplicitBrainyIframeMode(""), false);
|
||||
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
|
||||
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
|
||||
assert.equal(isExplicitBrainyIframeMode("?chatMode=iframe"), true);
|
||||
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
|
||||
});
|
||||
|
||||
test("keeps bare from=iframe on the original dedicated iframe protocol", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(shouldUseChatsIframeProtocol("?from=iframe"), true);
|
||||
assert.equal(hasIframePresentationOverride("?from=iframe"), false);
|
||||
});
|
||||
|
||||
test("uses the dedicated AgentSelector route for a bare embedded host", () => {
|
||||
assert.equal(shouldUseChatsIframeProtocol("", true), true);
|
||||
assert.equal(
|
||||
buildEmbeddedIframeRedirectPath("?ticket=secret&theme=dark-blue"),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
|
||||
);
|
||||
assert.equal(
|
||||
resolveChatsIframeEntryRedirect(
|
||||
"/page/workspace/chats/new",
|
||||
"",
|
||||
true,
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe",
|
||||
);
|
||||
assert.equal(
|
||||
resolveChatsIframeEntryRedirect(
|
||||
"/page/workspace/chats/thread-123",
|
||||
"",
|
||||
true,
|
||||
),
|
||||
"/page/workspace/chats_iframe/thread-123?from=iframe",
|
||||
);
|
||||
assert.equal(shouldUseChatsIframeProtocol("?chatMode=full", true), false);
|
||||
assert.equal(shouldUseChatsIframeProtocol("?hideChrome=1", true), false);
|
||||
assert.equal(
|
||||
buildStandardChatRedirectPath("?hideChrome=1"),
|
||||
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
|
||||
);
|
||||
});
|
||||
|
||||
test("routes legacy iframe links with standard presentation flags to full chat", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&hideHeader=1",
|
||||
),
|
||||
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&hideChrome=1",
|
||||
),
|
||||
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&embed=1&theme=dark-blue",
|
||||
),
|
||||
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
|
||||
);
|
||||
assert.equal(shouldUseChatsIframeProtocol("?from=iframe&hideChrome=1"), false);
|
||||
assert.equal(hasIframePresentationOverride("?from=iframe&embed=1"), true);
|
||||
});
|
||||
|
||||
test("redirects chats_iframe routes without the iframe source to standard full chat", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/thread-123",
|
||||
"?hideChrome=1",
|
||||
),
|
||||
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?chatMode=iframe",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback("/page/workspace/chats/new", ""),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("honors the dark-blue theme query on chats_iframe routes", () => {
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&theme=dark-blue",
|
||||
),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&theme=red",
|
||||
),
|
||||
"light",
|
||||
);
|
||||
});
|
||||
|
||||
test("forces the iframe theme on the login route without changing normal login", () => {
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
"/login",
|
||||
"?from=iframe&theme=dark-blue",
|
||||
),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
"/login/guest",
|
||||
"?from=iframe&theme=dark-blue",
|
||||
),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/login", "?theme=dark-blue"),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
"/page/workspace/chats/new",
|
||||
"?from=iframe&theme=dark-blue",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
"/login",
|
||||
"?from=iframe&hideChrome=1&theme=dark-blue",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not add chatMode=iframe to the legacy post-login redirect", () => {
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
|
||||
);
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath("?from=iframe"),
|
||||
"/page/workspace/chats_iframe/new?from=iframe",
|
||||
);
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath(
|
||||
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses full chat after login when a legacy iframe link sets presentation", () => {
|
||||
assert.equal(
|
||||
resolvePostLoginRedirectPath(
|
||||
"?from=iframe&theme=dark-blue&yUserId=user-1",
|
||||
"/page/workspace/chats/new",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
|
||||
);
|
||||
assert.equal(
|
||||
resolvePostLoginRedirectPath(
|
||||
"?from=iframe&hideChrome=1&theme=dark-blue",
|
||||
"/page/workspace/chats/new",
|
||||
),
|
||||
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolvePostLoginRedirectPath(
|
||||
"?from=iframe&embed=1&theme=dark-blue",
|
||||
"/page/workspace/chats/new",
|
||||
),
|
||||
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"),
|
||||
"/page/workspace/chats/new",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves iframe theme when a new chat becomes a thread", () => {
|
||||
assert.equal(
|
||||
buildChatsIframeThreadPath(
|
||||
"/page/workspace",
|
||||
"thread-123",
|
||||
"?from=iframe&theme=dark-blue&ticket=secret",
|
||||
),
|
||||
"/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes direct regular-chat legacy links according to the same split", () => {
|
||||
assert.equal(
|
||||
resolveChatsIframeEntryRedirect(
|
||||
"/page/workspace/chats/new",
|
||||
"?from=iframe",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe",
|
||||
);
|
||||
assert.equal(
|
||||
resolveChatsIframeEntryRedirect(
|
||||
"/page/workspace/chats/thread-123",
|
||||
"?from=iframe&hideChrome=1",
|
||||
),
|
||||
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolveChatsIframeEntryRedirect(
|
||||
"/page/workspace/chats/new",
|
||||
"?from=iframe&embed=1&theme=dark-blue",
|
||||
),
|
||||
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds a standard full-chat URL without leaking the legacy marker", () => {
|
||||
assert.equal(
|
||||
buildStandardChatRedirectPath(
|
||||
"?from=iframe&hideHeader=1&ticket=secret",
|
||||
),
|
||||
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps full-chat presentation flags when the sidebar opens an agent or thread", () => {
|
||||
assert.equal(
|
||||
buildFullChatNavigationPath(
|
||||
"/page/workspace/chats/new?agent=agent-1",
|
||||
"?hideChrome=1&chatMode=full",
|
||||
),
|
||||
"/page/workspace/chats/new?agent=agent-1&hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
buildFullChatNavigationPath(
|
||||
"/page/workspace/chats/thread-123",
|
||||
"?embed=1&theme=dark-blue&chatMode=full",
|
||||
),
|
||||
"/page/workspace/chats/thread-123?embed=1&theme=dark-blue&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
buildFullChatNavigationPath("/page/workspace/chats/new", ""),
|
||||
"/page/workspace/chats/new",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves explicit Brainy iframe mode and theme across redirects", () => {
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath("?chatMode=iframe&theme=dark-blue&ticket=secret"),
|
||||
"/page/workspace/chats_iframe/new?chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
assert.equal(
|
||||
buildChatsIframeThreadPath(
|
||||
"/page/workspace",
|
||||
"thread-123",
|
||||
"?chatMode=iframe&theme=dark-blue",
|
||||
),
|
||||
"/page/workspace/chats_iframe/thread-123?chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
@@ -1,16 +1,341 @@
|
||||
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
||||
|
||||
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
}
|
||||
|
||||
/** Keep the accepted URL booleans aligned with embed-session.ts. */
|
||||
function isEnabledPresentationParam(search: string, key: string): boolean {
|
||||
const value = toSearchParams(search).get(key)?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes";
|
||||
}
|
||||
|
||||
export function isChatsIframeLogin(search: string): boolean {
|
||||
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
|
||||
}
|
||||
|
||||
function isExplicitFullChatMode(search: string): boolean {
|
||||
return (
|
||||
toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare login URL embedded by an external host has no query parameter to
|
||||
* survive the login redirect. Add the legacy source marker before building
|
||||
* the private chat URL so refreshes and subsequent thread navigation retain
|
||||
* the selected dedicated iframe workspace.
|
||||
*/
|
||||
export function buildEmbeddedIframeRedirectPath(search: string): string {
|
||||
const source = toSearchParams(search);
|
||||
if (!source.has("from")) {
|
||||
source.set("from", IFRAME_LOGIN_SOURCE);
|
||||
}
|
||||
return buildChatsIframeRedirectPath(source.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* `from=iframe` predates the regular embedded-chat presentation flags. Keep
|
||||
* the two concerns separate: this only answers whether the caller explicitly
|
||||
* asked PageLayout to alter global chrome, not whether it is an iframe page.
|
||||
*/
|
||||
export function hasIframePresentationOverride(search: string): boolean {
|
||||
return (
|
||||
isEnabledPresentationParam(search, "hideHeader") ||
|
||||
isEnabledPresentationParam(search, "hideChrome") ||
|
||||
isEnabledPresentationParam(search, "embed")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The private `chats_iframe` transport is the legacy default for
|
||||
* `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller
|
||||
* that also supplies one of the standard presentation flags deliberately uses
|
||||
* the ordinary full chat workspace instead.
|
||||
*/
|
||||
export function shouldUseChatsIframeProtocol(
|
||||
search: string,
|
||||
embeddedHost = false,
|
||||
): boolean {
|
||||
return (
|
||||
!hasIframePresentationOverride(search) &&
|
||||
(isExplicitBrainyIframeMode(search) ||
|
||||
isChatsIframeLogin(search) ||
|
||||
// The historical bare iframe integration enters `/login/admin` without
|
||||
// any route parameters. Treat that embedded context as the legacy
|
||||
// dedicated AgentSelector page, unless the caller explicitly selected
|
||||
// the standard full-chat mode.
|
||||
(embeddedHost && !isExplicitFullChatMode(search)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the regular full-chat destination for a legacy iframe entry that has
|
||||
* opted into a standard presentation mode. Keeping the display flags in the
|
||||
* address bar makes direct links and refreshes deterministic; `from` is
|
||||
* intentionally omitted so the legacy protocol cannot take over again.
|
||||
*/
|
||||
export function buildStandardChatRedirectPath(
|
||||
search: string,
|
||||
fallbackPath = `${STANDARD_CHATS_PATH}/new`,
|
||||
): string {
|
||||
const source = toSearchParams(search);
|
||||
const [pathname, existingQuery = ""] = fallbackPath.split("?");
|
||||
const target = new URLSearchParams(existingQuery);
|
||||
|
||||
for (const key of ["hideHeader", "hideChrome", "embed"] as const) {
|
||||
const value = source.get(key)?.trim();
|
||||
if (value && !target.has(key)) {
|
||||
target.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// `theme` is meaningful only for embed mode, but preserving it here keeps a
|
||||
// refreshed embedded page on the same forced theme before session storage is
|
||||
// restored.
|
||||
const theme = source.get("theme")?.trim();
|
||||
if (theme && target.get("embed")) {
|
||||
target.set("theme", theme);
|
||||
}
|
||||
|
||||
target.set("chatMode", "full");
|
||||
const query = target.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an explicitly selected standard-chat presentation while navigating
|
||||
* between `/chats/new` and a concrete chat thread. Without this, a click in
|
||||
* the full-chat sidebar drops `hideChrome=1&chatMode=full`; an iframe host
|
||||
* then looks like the legacy bare integration and is incorrectly redirected
|
||||
* to the private AgentSelector workspace.
|
||||
*
|
||||
* This deliberately whitelists presentation flags through
|
||||
* `buildStandardChatRedirectPath` instead of copying arbitrary login/query
|
||||
* values into user-facing chat URLs.
|
||||
*/
|
||||
export function buildFullChatNavigationPath(
|
||||
destinationPath: string,
|
||||
search: string,
|
||||
): string {
|
||||
return hasIframePresentationOverride(search) || isExplicitFullChatMode(search)
|
||||
? buildStandardChatRedirectPath(search, destinationPath)
|
||||
: destinationPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve only the presentation parameters consumed by the iframe workspace.
|
||||
* Login credentials must never be copied into the post-login address bar.
|
||||
*/
|
||||
export function buildChatsIframeRedirectPath(search: string): string {
|
||||
const source = toSearchParams(search);
|
||||
const target = new URLSearchParams();
|
||||
|
||||
if (isChatsIframeLogin(search)) {
|
||||
target.set("from", IFRAME_LOGIN_SOURCE);
|
||||
}
|
||||
|
||||
const requestedMode = source.get("chatMode")?.trim();
|
||||
if (
|
||||
requestedMode?.toLowerCase() === "iframe" ||
|
||||
(!isChatsIframeLogin(search) && requestedMode)
|
||||
) {
|
||||
target.set("chatMode", requestedMode);
|
||||
}
|
||||
|
||||
const theme = source.get("theme")?.trim();
|
||||
if (theme) {
|
||||
target.set("theme", theme);
|
||||
}
|
||||
|
||||
const query = target.toString();
|
||||
return query ? `${CHATS_IFRAME_PATH}?${query}` : CHATS_IFRAME_PATH;
|
||||
}
|
||||
|
||||
export function buildChatsIframeThreadPath(
|
||||
routeBase: string,
|
||||
threadId: string,
|
||||
search: string,
|
||||
): string {
|
||||
const base = routeBase.endsWith("/") ? routeBase.slice(0, -1) : routeBase;
|
||||
const query = buildChatsIframeRedirectPath(search).split("?")[1];
|
||||
const path = `${base}/chats_iframe/${encodeURIComponent(threadId)}`;
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export function resolvePostLoginRedirectPath(
|
||||
search: string,
|
||||
fallbackPath: string,
|
||||
): string {
|
||||
if (shouldUseChatsIframeProtocol(search)) {
|
||||
return buildChatsIframeRedirectPath(search);
|
||||
}
|
||||
|
||||
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
|
||||
? buildStandardChatRedirectPath(search, fallbackPath)
|
||||
: fallbackPath;
|
||||
}
|
||||
|
||||
export function isChatsIframeWorkspacePath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/page/workspace/chats_iframe/new" ||
|
||||
pathname === CHATS_IFRAME_PATH ||
|
||||
pathname.startsWith("/page/workspace/chats_iframe/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `from=iframe` link can arrive directly at a regular chat URL instead of
|
||||
* passing through LoginPage. Normalize both entry paths to the same dedicated
|
||||
* protocol workspace, including an existing thread id when one is supplied.
|
||||
*/
|
||||
export function resolveChatsIframeEntryRedirect(
|
||||
pathname: string,
|
||||
search: string,
|
||||
embeddedHost = false,
|
||||
): string | null {
|
||||
const isStandardChatPath =
|
||||
pathname === STANDARD_CHATS_PATH ||
|
||||
pathname === `${STANDARD_CHATS_PATH}/` ||
|
||||
pathname.startsWith(`${STANDARD_CHATS_PATH}/`);
|
||||
|
||||
// Standard presentation flags deliberately bypass the private iframe
|
||||
// protocol. Normalize direct legacy URLs once so the result stays on the
|
||||
// normal full-chat workspace and retains its agent/history sidebar.
|
||||
if (
|
||||
isChatsIframeLogin(search) &&
|
||||
hasIframePresentationOverride(search) &&
|
||||
!isExplicitBrainyIframeMode(search)
|
||||
) {
|
||||
return isStandardChatPath
|
||||
? buildStandardChatRedirectPath(search, pathname)
|
||||
: null;
|
||||
}
|
||||
|
||||
const inferredEmbeddedProtocol =
|
||||
embeddedHost &&
|
||||
!hasIframePresentationOverride(search) &&
|
||||
!isExplicitFullChatMode(search) &&
|
||||
!isChatsIframeLogin(search) &&
|
||||
!isExplicitBrainyIframeMode(search);
|
||||
const protocolSearch = inferredEmbeddedProtocol
|
||||
? `?${new URLSearchParams({
|
||||
...Object.fromEntries(toSearchParams(search)),
|
||||
from: IFRAME_LOGIN_SOURCE,
|
||||
}).toString()}`
|
||||
: search;
|
||||
const requestsIframeProtocol = shouldUseChatsIframeProtocol(
|
||||
protocolSearch,
|
||||
embeddedHost,
|
||||
);
|
||||
if (!requestsIframeProtocol) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) {
|
||||
return buildChatsIframeRedirectPath(protocolSearch);
|
||||
}
|
||||
|
||||
if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length);
|
||||
if (!routeSuffix || routeSuffix.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (routeSuffix === "new") {
|
||||
return buildChatsIframeRedirectPath(protocolSearch);
|
||||
}
|
||||
|
||||
try {
|
||||
return buildChatsIframeThreadPath(
|
||||
"/page/workspace",
|
||||
decodeURIComponent(routeSuffix),
|
||||
protocolSearch,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lightweight iframe page is a private transport for the Brainy
|
||||
* postMessage protocol, not a general-purpose embedded chat page.
|
||||
*
|
||||
* `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
|
||||
* iframe/Wujie embedding without either marker remains standard chat.
|
||||
*/
|
||||
export function isExplicitBrainyIframeMode(search: string): boolean {
|
||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||
return (
|
||||
new URLSearchParams(normalizedSearch)
|
||||
.get("chatMode")
|
||||
?.trim()
|
||||
.toLowerCase() === "iframe"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an accidental/legacy `chats_iframe` URL to the equivalent standard
|
||||
* chat URL. Existing thread ids and unrelated query parameters are retained.
|
||||
*/
|
||||
export function resolveStandardChatFallback(
|
||||
pathname: string,
|
||||
search: string,
|
||||
): string | null {
|
||||
if (!isChatsIframeWorkspacePath(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||
const params = new URLSearchParams(normalizedSearch);
|
||||
const routePrefix = "/page/workspace/chats_iframe/";
|
||||
const routeSuffix = pathname.slice(routePrefix.length);
|
||||
const targetPath =
|
||||
routeSuffix && routeSuffix !== "new"
|
||||
? `/page/workspace/chats/${routeSuffix}`
|
||||
: "/page/workspace/chats/new";
|
||||
|
||||
if (
|
||||
isChatsIframeLogin(search) &&
|
||||
hasIframePresentationOverride(search) &&
|
||||
!isExplicitBrainyIframeMode(search)
|
||||
) {
|
||||
return buildStandardChatRedirectPath(search, targetPath);
|
||||
}
|
||||
|
||||
if (shouldUseChatsIframeProtocol(search)) {
|
||||
// The legacy marker itself is enough. Do not add chatMode=iframe here: it
|
||||
// is an implementation detail and adding it caused a self-redirect loop.
|
||||
return null;
|
||||
}
|
||||
params.delete("from");
|
||||
params.set("chatMode", "full");
|
||||
const targetSearch = params.toString();
|
||||
return targetSearch ? `${targetPath}?${targetSearch}` : targetPath;
|
||||
}
|
||||
|
||||
export function resolveForcedThemeForPath(
|
||||
pathname: string,
|
||||
): "light" | undefined {
|
||||
if (pathname === "/landing" || isChatsIframeWorkspacePath(pathname)) {
|
||||
search = "",
|
||||
): "light" | "dark" | undefined {
|
||||
if (pathname === "/landing") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||
if (
|
||||
isChatsIframeWorkspacePath(pathname) ||
|
||||
(isLoginPath && shouldUseChatsIframeProtocol(search))
|
||||
) {
|
||||
const rawTheme = toSearchParams(search).get("theme")?.trim();
|
||||
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: Users,
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
},
|
||||
// {
|
||||
// id: "position-roundtable",
|
||||
// label: "岗位会商",
|
||||
// icon: Briefcase,
|
||||
// path: "/page/strategy/qa/position-roundtable",
|
||||
// },
|
||||
{
|
||||
id: "dashboard-agent",
|
||||
label: "大屏绘制",
|
||||
@@ -264,6 +270,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/positions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-position-roles",
|
||||
label: "岗位角色配置",
|
||||
icon: Briefcase,
|
||||
path: "/page/strategy/admin/position-roles",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
|
||||
@@ -187,4 +187,4 @@ test("unmapped incoming url resets auto-selected agent back to default when ther
|
||||
forceDefaultAssistant: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { desensitize, makeDesensitizer } from "@/lib/desensitize";
|
||||
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
|
||||
|
||||
/**
|
||||
* 返回用于界面静态文本的脱敏函数。
|
||||
*
|
||||
* 与导航菜单使用同一份敏感词查询:后台存在配置时以后台为准,未配置时回退到内置种子。
|
||||
* Hook 订阅 React Query 缓存,管理员保存敏感词后,使用它的导航/欢迎区会自动重新渲染。
|
||||
*/
|
||||
export function useDisplayDesensitizer(): (text: string) => string {
|
||||
const { data: words = [] } = useSensitiveWords();
|
||||
|
||||
return useMemo(
|
||||
() => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize),
|
||||
[words],
|
||||
);
|
||||
}
|
||||
@@ -276,7 +276,42 @@ export async function updatePublishSquares(
|
||||
return data;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||
export const BUILTIN_THEME_IDS = [
|
||||
"light",
|
||||
"dark",
|
||||
"lightblue",
|
||||
"lightgreen",
|
||||
"iceblue",
|
||||
"lightyellow",
|
||||
] as const;
|
||||
|
||||
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
|
||||
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
|
||||
export type CustomThemeBaseMode = "light" | "dark";
|
||||
|
||||
export interface CustomThemeColorOverrides {
|
||||
button_hover?: string | null;
|
||||
navigation_gradient_start?: string | null;
|
||||
navigation_gradient_end?: string | null;
|
||||
selected_background?: string | null;
|
||||
border?: string | null;
|
||||
muted?: string | null;
|
||||
muted_text?: string | null;
|
||||
destructive?: string | null;
|
||||
brand_title?: string | null;
|
||||
primary_foreground?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomThemePreset {
|
||||
id: `custom-theme-${string}`;
|
||||
name: string;
|
||||
primary: string;
|
||||
text_color?: string | null;
|
||||
mode: CustomThemeBaseMode;
|
||||
overrides?: CustomThemeColorOverrides;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
export type ViewMode = "card" | "list";
|
||||
export type LayoutMode = "full" | "compact";
|
||||
export type CompactShortcutParamType = "login" | "theme";
|
||||
@@ -304,6 +339,7 @@ export interface AppearanceSettings {
|
||||
default_agent_view: ViewMode;
|
||||
default_layout_mode: LayoutMode;
|
||||
compact_shortcut_buttons: CompactShortcutButton[];
|
||||
custom_themes: CustomThemePreset[];
|
||||
}
|
||||
|
||||
export interface AppearanceUpdate {
|
||||
@@ -312,6 +348,7 @@ export interface AppearanceUpdate {
|
||||
default_agent_view?: ViewMode;
|
||||
default_layout_mode?: LayoutMode;
|
||||
compact_shortcut_buttons?: CompactShortcutButton[];
|
||||
custom_themes?: CustomThemePreset[];
|
||||
}
|
||||
|
||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||
@@ -469,12 +506,16 @@ export interface ModelConcurrencySettings {
|
||||
enabled: boolean;
|
||||
default_limit: number;
|
||||
model_limits: Record<string, number>;
|
||||
user_default_limit: number;
|
||||
user_model_limits: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencyUpdate {
|
||||
enabled?: boolean;
|
||||
default_limit?: number;
|
||||
model_limits?: Record<string, number>;
|
||||
user_default_limit?: number;
|
||||
user_model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
|
||||
@@ -111,10 +111,13 @@ export function useUpdatePublishSquares() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
export function useAppearanceSettings(
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: APPEARANCE_KEY,
|
||||
queryFn: () => loadAppearanceSettings(),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
// The provider runs even on the login page (where the user has no auth)
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import type {
|
||||
CustomThemeColorOverrides,
|
||||
CustomThemePreset,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const CUSTOM_THEME_PREFIX = "custom-theme-";
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
|
||||
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
|
||||
|
||||
export const CUSTOM_THEME_ADVANCED_KEYS = [
|
||||
"button_hover",
|
||||
"navigation_gradient_start",
|
||||
"navigation_gradient_end",
|
||||
"selected_background",
|
||||
"border",
|
||||
"muted",
|
||||
"muted_text",
|
||||
"destructive",
|
||||
"brand_title",
|
||||
"primary_foreground",
|
||||
] as const;
|
||||
|
||||
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
|
||||
|
||||
export type CustomThemeTokenInput = Pick<
|
||||
CustomThemePreset,
|
||||
"primary" | "mode"
|
||||
> &
|
||||
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
|
||||
|
||||
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
|
||||
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
|
||||
}
|
||||
|
||||
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return HEX_COLOR.test(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function optionalHexColor(value: string | null | undefined): string | undefined {
|
||||
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
function toRgb(value: string): Rgb {
|
||||
const hex = normalizeHexColor(value).slice(1);
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(0, 2), 16),
|
||||
g: Number.parseInt(hex.slice(2, 4), 16),
|
||||
b: Number.parseInt(hex.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function toHex({ r, g, b }: Rgb): string {
|
||||
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
|
||||
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
|
||||
const ratio = Math.min(1, Math.max(0, amount));
|
||||
return {
|
||||
r: from.r + (to.r - from.r) * ratio,
|
||||
g: from.g + (to.g - from.g) * ratio,
|
||||
b: from.b + (to.b - from.b) * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
function relativeLuminance({ r, g, b }: Rgb): number {
|
||||
const linear = [r, g, b].map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
|
||||
}
|
||||
|
||||
function contrastRatio(a: Rgb, b: Rgb): number {
|
||||
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
|
||||
return (lighter! + 0.05) / (darker! + 0.05);
|
||||
}
|
||||
|
||||
function readableForeground(background: Rgb): string {
|
||||
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
|
||||
}
|
||||
|
||||
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
|
||||
let candidate = color;
|
||||
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
|
||||
candidate = mix(candidate, toward, 0.18);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function rgbChannels(color: Rgb): string {
|
||||
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
|
||||
* Inputs are restricted to a safe six-digit primary colour, optional primary
|
||||
* button text colour, a small set of interaction overrides and a light/dark surface
|
||||
* family, so callers never interpolate arbitrary CSS values.
|
||||
*/
|
||||
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
|
||||
const primary = toRgb(theme.primary);
|
||||
const isDark = theme.mode === "dark";
|
||||
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
|
||||
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
|
||||
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
|
||||
// Normal page copy stays automatic so selecting white for a teal/blue
|
||||
// action does not accidentally make a light-page's body text white.
|
||||
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
|
||||
const foregroundRgb = ensureContrast(
|
||||
preferredForeground,
|
||||
background,
|
||||
toRgb(readableForeground(background)),
|
||||
);
|
||||
const foreground = toHex(foregroundRgb);
|
||||
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
|
||||
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
|
||||
? ensureContrast(
|
||||
toRgb(overrides.muted_text!),
|
||||
background,
|
||||
toRgb(readableForeground(background)),
|
||||
3,
|
||||
)
|
||||
: defaultMutedForeground;
|
||||
const mutedForeground = toHex(mutedForegroundRgb);
|
||||
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
|
||||
const accent = optionalHexColor(overrides.selected_background)
|
||||
? toRgb(overrides.selected_background!)
|
||||
: defaultAccent;
|
||||
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
|
||||
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
|
||||
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
|
||||
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
|
||||
const action = ensureContrast(
|
||||
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
|
||||
isDark ? background : WHITE,
|
||||
isDark ? WHITE : BLACK,
|
||||
);
|
||||
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
|
||||
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
|
||||
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
|
||||
const primaryHover = optionalHexColor(overrides.button_hover)
|
||||
? toRgb(overrides.button_hover!)
|
||||
: defaultPrimaryHover;
|
||||
// Keep the same left → main-colour → right treatment used by the built-in
|
||||
// themes. These two values remain automatic unless the administrator opens
|
||||
// “更多配色” and explicitly overrides one of them.
|
||||
const defaultNavigationGradientStart = mix(primary, BLACK, isDark ? 0.46 : 0.3);
|
||||
const defaultNavigationGradientEnd = mix(primary, WHITE, isDark ? 0.14 : 0.42);
|
||||
const navigationGradientStart = optionalHexColor(overrides.navigation_gradient_start)
|
||||
? toRgb(overrides.navigation_gradient_start!)
|
||||
: defaultNavigationGradientStart;
|
||||
const navigationGradientEnd = optionalHexColor(overrides.navigation_gradient_end)
|
||||
? toRgb(overrides.navigation_gradient_end!)
|
||||
: defaultNavigationGradientEnd;
|
||||
const navigationForeground = readableForeground(mix(navigationGradientStart, navigationGradientEnd, 0.5));
|
||||
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
|
||||
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
|
||||
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
|
||||
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
|
||||
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
|
||||
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
|
||||
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
|
||||
const ring = rgbChannels(primary);
|
||||
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
|
||||
const primaryHex = toHex(primary);
|
||||
const actionHex = toHex(action);
|
||||
const actionHoverHex = toHex(actionHover);
|
||||
const actionActiveHex = toHex(actionActive);
|
||||
const accentHex = toHex(accent);
|
||||
const accentHoverHex = toHex(accentHover);
|
||||
const backgroundHex = toHex(background);
|
||||
const cardHex = toHex(card);
|
||||
const borderHex = toHex(border);
|
||||
const inputHex = toHex(input);
|
||||
const secondaryHex = toHex(secondary);
|
||||
const primaryHoverHex = toHex(primaryHover);
|
||||
const primaryActiveHex = toHex(primaryActive);
|
||||
const navigationGradientStartHex = toHex(navigationGradientStart);
|
||||
const navigationGradientEndHex = toHex(navigationGradientEnd);
|
||||
const sidebarHex = toHex(sidebar);
|
||||
|
||||
return {
|
||||
"--workspace-brand": primaryHex,
|
||||
"--workspace-brand-foreground": primaryForeground,
|
||||
"--brand-200": accentHex,
|
||||
"--brand-900": actionHex,
|
||||
"--brand-950": actionActiveHex,
|
||||
"--theme-button-hover": primaryHoverHex,
|
||||
"--navigation-gradient-start": navigationGradientStartHex,
|
||||
"--navigation-gradient-end": navigationGradientEndHex,
|
||||
"--navigation-foreground": navigationForeground,
|
||||
"--theme-selected-background": accentHex,
|
||||
"--background": backgroundHex,
|
||||
"--foreground": foreground,
|
||||
"--card": cardHex,
|
||||
"--card-foreground": foreground,
|
||||
"--popover": cardHex,
|
||||
"--popover-foreground": foreground,
|
||||
"--primary": primaryHex,
|
||||
"--primary-foreground": primaryForeground,
|
||||
"--button-foreground": buttonForeground,
|
||||
"--brand-title-foreground": brandTitle,
|
||||
"--secondary": secondaryHex,
|
||||
"--secondary-foreground": actionHex,
|
||||
"--muted": secondaryHex,
|
||||
"--muted-foreground": mutedForeground,
|
||||
"--accent": accentHex,
|
||||
"--accent-foreground": actionHex,
|
||||
"--destructive": destructive,
|
||||
"--border": borderHex,
|
||||
"--input": inputHex,
|
||||
"--ring": `rgb(${ring} / 0.48)`,
|
||||
"--chart-1": actionHex,
|
||||
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
|
||||
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
|
||||
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
|
||||
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
|
||||
"--sidebar": sidebarHex,
|
||||
"--sidebar-foreground": foreground,
|
||||
"--sidebar-primary": primaryHex,
|
||||
"--sidebar-primary-foreground": primaryForeground,
|
||||
"--sidebar-accent": accentHex,
|
||||
"--sidebar-accent-foreground": actionHex,
|
||||
"--sidebar-border": borderHex,
|
||||
"--sidebar-ring": `rgb(${ring} / 0.48)`,
|
||||
"--legacy-accent-rgb": rgbChannels(accent),
|
||||
"--legacy-background-rgb": rgbChannels(background),
|
||||
"--legacy-border-rgb": rgbChannels(border),
|
||||
"--legacy-brand-200-rgb": rgbChannels(accent),
|
||||
"--legacy-card-rgb": rgbChannels(card),
|
||||
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
|
||||
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
|
||||
"--legacy-input-rgb": rgbChannels(input),
|
||||
"--legacy-muted-rgb": rgbChannels(secondary),
|
||||
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
|
||||
"--legacy-primary-rgb": rgbChannels(primary),
|
||||
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
|
||||
"--legacy-ring-rgb": ring,
|
||||
"--legacy-secondary-rgb": rgbChannels(secondary),
|
||||
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
|
||||
"--legacy-sidebar-border-rgb": rgbChannels(border),
|
||||
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
|
||||
"--legacy-workspace-brand-rgb": rgbChannels(primary),
|
||||
"--td-brand-color-1": backgroundHex,
|
||||
"--td-brand-color-2": secondaryHex,
|
||||
"--td-brand-color-3": accentHex,
|
||||
"--td-brand-color-4": accentHoverHex,
|
||||
"--td-brand-color-5": primaryHex,
|
||||
"--td-brand-color-6": primaryHex,
|
||||
"--td-brand-color-7": actionHex,
|
||||
"--td-brand-color-8": actionHoverHex,
|
||||
"--td-brand-color-9": actionActiveHex,
|
||||
"--td-brand-color-10": actionActiveHex,
|
||||
"--td-brand-color": primaryHex,
|
||||
"--td-brand-color-hover": primaryHoverHex,
|
||||
"--td-brand-color-focus": accentHex,
|
||||
"--td-brand-color-active": primaryActiveHex,
|
||||
"--td-brand-color-disabled": accentHoverHex,
|
||||
"--td-brand-color-light": accentHex,
|
||||
"--td-brand-color-light-hover": accentHoverHex,
|
||||
"--td-text-color-primary": foreground,
|
||||
"--td-text-color-secondary": mutedForeground,
|
||||
"--td-text-color-brand": actionHex,
|
||||
"--td-text-color-link": actionHex,
|
||||
"--td-text-color-anti": buttonForeground,
|
||||
"--td-bg-color-page": backgroundHex,
|
||||
"--td-bg-color-container": cardHex,
|
||||
"--td-bg-color-container-hover": secondaryHex,
|
||||
"--td-bg-color-container-active": accentHex,
|
||||
"--td-bg-color-secondarycontainer": secondaryHex,
|
||||
"--td-bg-color-secondarycontainer-hover": accentHex,
|
||||
"--td-bg-color-secondarycontainer-active": accentHoverHex,
|
||||
"--td-border-level-1-color": borderHex,
|
||||
"--td-component-stroke": borderHex,
|
||||
"--td-border-level-2-color": inputHex,
|
||||
"--td-component-border": inputHex,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tokens produced from the automatic palette only, used by the editor to
|
||||
* show which advanced values are currently inheriting from the system. */
|
||||
export function getCustomThemeAutomaticTokens(
|
||||
theme: Pick<CustomThemePreset, "primary" | "mode">,
|
||||
): Record<string, string> {
|
||||
return getCustomThemeTokens(theme);
|
||||
}
|
||||
|
||||
export function customThemeContrast(primary: string): number {
|
||||
const color = toRgb(primary);
|
||||
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
|
||||
}
|
||||
|
||||
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
|
||||
const tokens = getCustomThemeTokens(theme);
|
||||
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
|
||||
}
|
||||
@@ -30,11 +30,20 @@ export type ToolEndEvent = {
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
isMock?: boolean;
|
||||
assistantId?: string;
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
isMock?: boolean;
|
||||
assistantId?: string;
|
||||
/**
|
||||
* Per-page run behaviour. Most chats keep the SDK defaults; long-running
|
||||
* workspace flows can opt into a resumable stream without changing global
|
||||
* chat semantics.
|
||||
*/
|
||||
runOptions?: {
|
||||
onDisconnect?: "cancel" | "continue";
|
||||
multitaskStrategy?: "reject" | "rollback" | "interrupt" | "enqueue";
|
||||
};
|
||||
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||
threadMetadata?: Record<string, unknown>;
|
||||
@@ -45,11 +54,45 @@ export type ThreadStreamOptions = {
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
/**
|
||||
* Extra human-context messages submitted before the visible user message.
|
||||
* They are persisted with `hide_from_ui` so the model receives rich business
|
||||
* context without dumping long prompts into the conversation surface.
|
||||
*/
|
||||
hiddenContext?:
|
||||
| string
|
||||
| {
|
||||
text: string;
|
||||
name?: string;
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
}
|
||||
| Array<{
|
||||
text: string;
|
||||
name?: string;
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
|
||||
title_provisional?: boolean;
|
||||
};
|
||||
|
||||
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
|
||||
const value = options?.hiddenContext;
|
||||
if (!value) return [];
|
||||
const items =
|
||||
typeof value === "string"
|
||||
? [{ text: value }]
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [value];
|
||||
return items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
text: item.text.trim(),
|
||||
}))
|
||||
.filter((item) => item.text.length > 0);
|
||||
}
|
||||
|
||||
function normalizeStoredRunId(runId: string | null): string | null {
|
||||
if (!runId) {
|
||||
@@ -182,6 +225,22 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
"success",
|
||||
"error",
|
||||
"timeout",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
// A completed server run normally sends a terminal SSE event. In local
|
||||
// development, however, a Vite hot update, a proxy disconnect, or a brief
|
||||
// network interruption can make the browser miss that final event while the
|
||||
// backend continues (and successfully completes) the run. Do not probe on
|
||||
// every render: only treat the stream as suspicious after it has been quiet
|
||||
// for a while, then reconcile it from the authoritative run/state endpoints.
|
||||
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
||||
const STREAM_RECONCILIATION_POLL_MS = 5_000;
|
||||
|
||||
async function fetchThreadTitleRefreshState(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -263,11 +322,24 @@ function isModelConcurrencyLimitError(message: string): boolean {
|
||||
return (
|
||||
/\b429\b/.test(message) ||
|
||||
/并发已达到上限/.test(message) ||
|
||||
/并发调用已达到上限/.test(message) ||
|
||||
/使用人数较多/.test(message) ||
|
||||
/too many requests/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function friendlySendLimitMessage(message: string): string | null {
|
||||
if (isActiveRunConflictError(message)) {
|
||||
return "当前对话还在生成回答,请等待本轮回复结束,或先点“停止”再发送新的问题。";
|
||||
}
|
||||
if (isModelConcurrencyLimitError(message)) {
|
||||
return /当前账号对该模型/.test(message)
|
||||
? "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。"
|
||||
: "当前使用人数较多,请稍等一下再试。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCurrentUserLabel(): string {
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) {
|
||||
@@ -371,9 +443,10 @@ function lastHumanFromChatModelInput(input: unknown): string {
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
context,
|
||||
isMock,
|
||||
assistantId,
|
||||
threadMetadata,
|
||||
isMock,
|
||||
assistantId,
|
||||
runOptions,
|
||||
threadMetadata,
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
@@ -384,8 +457,14 @@ export function useThreadStream({
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
// and to allow access to the current thread id in onUpdateEvent
|
||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||
const startedRef = useRef(false);
|
||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||
const startedRef = useRef(false);
|
||||
const lastStreamActivityAtRef = useRef(0);
|
||||
const streamReconciliationInFlightRef = useRef(false);
|
||||
|
||||
const markStreamActivity = useCallback(() => {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
@@ -417,13 +496,14 @@ export function useThreadStream({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStreamStart = useCallback(
|
||||
(_threadId: string) => {
|
||||
threadIdRef.current = _threadId;
|
||||
_handleOnStart(_threadId);
|
||||
},
|
||||
[_handleOnStart],
|
||||
);
|
||||
const handleStreamStart = useCallback(
|
||||
(_threadId: string) => {
|
||||
threadIdRef.current = _threadId;
|
||||
markStreamActivity();
|
||||
_handleOnStart(_threadId);
|
||||
},
|
||||
[_handleOnStart, markStreamActivity],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
@@ -447,8 +527,8 @@ export function useThreadStream({
|
||||
? () => runMetadataStorageRef.current!
|
||||
: false,
|
||||
fetchStateHistory: { limit: 1 },
|
||||
onCreated(meta) {
|
||||
handleStreamStart(meta.thread_id);
|
||||
onCreated(meta) {
|
||||
handleStreamStart(meta.thread_id);
|
||||
setOnStreamThreadId(meta.thread_id);
|
||||
const metadataAgentId = context.agent_id ?? context.agent_name;
|
||||
const extraMetadata =
|
||||
@@ -462,9 +542,10 @@ export function useThreadStream({
|
||||
.threads.update(meta.thread_id, { metadata })
|
||||
.catch(() => ({}));
|
||||
}
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
if (event.event === "on_tool_end") {
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
@@ -492,9 +573,10 @@ export function useThreadStream({
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdateEvent(data) {
|
||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||
},
|
||||
onUpdateEvent(data) {
|
||||
markStreamActivity();
|
||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||
data || {},
|
||||
);
|
||||
for (const update of updates) {
|
||||
@@ -515,9 +597,10 @@ export function useThreadStream({
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
if (
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
markStreamActivity();
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
@@ -561,9 +644,10 @@ export function useThreadStream({
|
||||
const e = event as { type: "context_compacting"; message: string };
|
||||
toast(e.message);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
},
|
||||
onError(error) {
|
||||
markStreamActivity();
|
||||
setOptimisticMessages([]);
|
||||
const message = getStreamErrorMessage(error);
|
||||
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
|
||||
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
|
||||
@@ -613,22 +697,20 @@ export function useThreadStream({
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是
|
||||
// 把原始的英文/HTTP 错误抛给用户。
|
||||
if (isActiveRunConflictError(message)) {
|
||||
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
|
||||
return;
|
||||
}
|
||||
if (isModelConcurrencyLimitError(message)) {
|
||||
toast.info("当前使用人数较多,请稍等一下再试。");
|
||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
||||
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
|
||||
const friendly = friendlySendLimitMessage(message);
|
||||
if (friendly) {
|
||||
toast.info(friendly);
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
},
|
||||
onFinish(state) {
|
||||
markStreamActivity();
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic messages shown before the server stream responds
|
||||
@@ -651,8 +733,97 @@ export function useThreadStream({
|
||||
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
||||
// an in-flight stream without re-running on every render that produces a
|
||||
// new `thread` identity.
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread.isLoading) {
|
||||
// A normal terminal event reached the browser. A later run may use the
|
||||
// same thread, so it must be eligible for recovery again.
|
||||
streamReconciliationInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
if (!activeThreadId || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let refreshTimer: number | null = null;
|
||||
|
||||
const reconcileCompletedRun = async () => {
|
||||
if (
|
||||
disposed ||
|
||||
streamReconciliationInFlightRef.current ||
|
||||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = runMetadataStorageRef.current;
|
||||
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
|
||||
if (!runId) return;
|
||||
|
||||
streamReconciliationInFlightRef.current = true;
|
||||
try {
|
||||
const client = getAPIClient(isMock);
|
||||
const run = await client.runs.get(activeThreadId, runId);
|
||||
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The server is already terminal, so the existing SSE listener cannot
|
||||
// receive any more useful events. Load the checkpoint directly before
|
||||
// resetting the SDK stream; this preserves the normal onFinish side
|
||||
// effects (e.g. marking a position delivery complete) even when the
|
||||
// browser missed the terminal event.
|
||||
const finalState = await client.threads.getState(activeThreadId);
|
||||
if (disposed || !threadStreamRef.current.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||
if (run.status === "success") {
|
||||
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} else {
|
||||
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
|
||||
}
|
||||
|
||||
// Changing the controlled thread id clears only the local SDK stream
|
||||
// (it does not cancel the already-terminal backend run). Switching it
|
||||
// back immediately makes useStream fetch the persisted messages, so
|
||||
// the page exits the false loading state without a manual refresh.
|
||||
setOnStreamThreadId(null);
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
if (!disposed && threadIdRef.current === activeThreadId) {
|
||||
setOnStreamThreadId(activeThreadId);
|
||||
}
|
||||
}, 0);
|
||||
} catch {
|
||||
// A transient status lookup failure must not disrupt a real stream;
|
||||
// the next polling interval will try again if it is still quiet.
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
streamReconciliationInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = window.setInterval(
|
||||
() => void reconcileCompletedRun(),
|
||||
STREAM_RECONCILIATION_POLL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(interval);
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
};
|
||||
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
|
||||
|
||||
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||
@@ -965,32 +1136,51 @@ export function useThreadStream({
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
||||
const submittedMessages: Message[] = [
|
||||
...hiddenContextMessages.map((item) => ({
|
||||
type: "human" as const,
|
||||
...(item.name ? { name: item.name } : {}),
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: item.text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...item.additionalKwargs,
|
||||
hide_from_ui: true,
|
||||
},
|
||||
})),
|
||||
{
|
||||
type: "human" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: submittedMessages,
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
onDisconnect: runOptions?.onDisconnect,
|
||||
multitaskStrategy: runOptions?.multitaskStrategy,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
@@ -1017,15 +1207,35 @@ export function useThreadStream({
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} catch (error) {
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
throw error;
|
||||
} finally {
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const friendly = friendlySendLimitMessage(message);
|
||||
if (friendly) {
|
||||
setOptimisticMessages((messages) => {
|
||||
const human = messages.find((item) => item.type === "human");
|
||||
const kept = human ? [human] : [];
|
||||
return [
|
||||
...kept,
|
||||
{
|
||||
type: "ai",
|
||||
id: `opt-ai-limit-${Date.now()}`,
|
||||
content: friendly,
|
||||
additional_kwargs: { element: "task" },
|
||||
} as Message,
|
||||
];
|
||||
});
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
throw error;
|
||||
} finally {
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
@@ -1300,8 +1510,8 @@ export function useDeleteThread() {
|
||||
* `MessageList`) so the removal shows immediately; a reload then reads the
|
||||
* persisted, already-trimmed state.
|
||||
*/
|
||||
export function useDeleteMessage() {
|
||||
return useMutation({
|
||||
export function useDeleteMessage() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
messageId,
|
||||
@@ -1331,12 +1541,92 @@ export function useDeleteMessage() {
|
||||
// Fall back to the requested id so the caller can always hide something.
|
||||
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge metadata into a conversation's record via the Gateway
|
||||
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages from a thread checkpoint. The caller should immediately
|
||||
* hide the returned ids locally because the active stream cache may still hold
|
||||
* the old message objects until a reload/refetch reads the trimmed checkpoint.
|
||||
*/
|
||||
export function useClearThreadMessages() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
}: {
|
||||
threadId: string;
|
||||
}): Promise<string[]> => {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||
threadId,
|
||||
)}/messages`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "清空对话失败。" }));
|
||||
throw new Error(error.detail ?? "清空对话失败。");
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
deleted_ids?: string[];
|
||||
};
|
||||
return body.deleted_ids ?? [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an in-place message edit in the latest thread checkpoint.
|
||||
*
|
||||
* This is opt-in UI behaviour: most chat pages keep their existing
|
||||
* "edit question and resubmit" affordance, while position roundtable can pass
|
||||
* this mutation down to save the original message content itself.
|
||||
*/
|
||||
export function useEditMessage() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
messageId,
|
||||
content,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
}): Promise<Message> => {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||
threadId,
|
||||
)}/messages/${encodeURIComponent(messageId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "消息保存失败。" }));
|
||||
throw new Error(error.detail ?? "消息保存失败。");
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
message?: Message;
|
||||
};
|
||||
return body.message ?? ({ id: messageId, type: "human", content } as Message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge metadata into a conversation's record via the Gateway
|
||||
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
||||
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
|
||||
* single user, so these live directly on the thread metadata (no join table).
|
||||
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Save, Settings2, X } from "lucide-react";
|
||||
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import { listChains } from "@/roundtable-planning/api/chains";
|
||||
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||
import {
|
||||
listBusinessMappings,
|
||||
updateBusinessMapping,
|
||||
type BusinessMapping,
|
||||
} from "@/strategy-components/api/business-mapping";
|
||||
|
||||
import "../styles/step1.css";
|
||||
import "../styles/step2.css";
|
||||
import "../styles/step3.css";
|
||||
|
||||
const BUSINESS_CODE = "8BF";
|
||||
|
||||
/**
|
||||
* 8BF 独立多智能体会商页。
|
||||
*
|
||||
* 页面复用圆桌会商的三步能力,但业务链选择、草稿存储键和 CSS 根节点完全独立;
|
||||
* 不引用岗位会商的页面、会话或接口。
|
||||
*/
|
||||
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
|
||||
const auth = getStoredAuth();
|
||||
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
|
||||
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
|
||||
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
|
||||
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
|
||||
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [selectedChainId, setSelectedChainId] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const configuredChain = useMemo(
|
||||
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
||||
[chains, mapping?.chainId],
|
||||
);
|
||||
|
||||
const loadConfiguration = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [mappings, availableChains] = await Promise.all([
|
||||
listBusinessMappings(),
|
||||
listChains("public"),
|
||||
]);
|
||||
const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null;
|
||||
// 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。
|
||||
const nextChains = availableChains.filter((chain) => chain.isPublic);
|
||||
setMapping(nextMapping);
|
||||
setChains(nextChains);
|
||||
setSelectedChainId(nextMapping?.chainId ?? "");
|
||||
} catch {
|
||||
setError("业务链配置加载失败,请刷新后重试");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfiguration();
|
||||
}, [loadConfiguration]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canConfigureChain) {
|
||||
setError("仅 lqq 账号可以配置 8BF 业务链条");
|
||||
return;
|
||||
}
|
||||
const selected = chains.find((chain) => chain.id === selectedChainId);
|
||||
if (!selected) {
|
||||
setError("请选择一条已发布的业务链");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await updateBusinessMapping(BUSINESS_CODE, {
|
||||
chainId: selected.id,
|
||||
chainTitle: selected.title,
|
||||
});
|
||||
if (!next) throw new Error("8BF 映射不存在");
|
||||
setMapping(next);
|
||||
setIsSettingsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "保存业务链失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [canConfigureChain, chains, selectedChainId]);
|
||||
|
||||
const toolbarSlot = (
|
||||
<>
|
||||
{canConfigureChain && (
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__chain-settings-button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
配置业务链
|
||||
</button>
|
||||
)}
|
||||
{canConfigureChain && isSettingsOpen && (
|
||||
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
||||
<section
|
||||
className="eightbf-roundtable__settings-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="eightbf-chain-settings-title"
|
||||
>
|
||||
<div className="eightbf-roundtable__settings-heading">
|
||||
<div>
|
||||
<p className="eightbf-roundtable__settings-kicker">管理员配置</p>
|
||||
<h2 id="eightbf-chain-settings-title">设置 8BF 公共业务链</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__settings-close"
|
||||
onClick={() => setIsSettingsOpen(false)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="eightbf-roundtable__settings-tip">
|
||||
保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。
|
||||
</p>
|
||||
<label className="eightbf-roundtable__settings-field">
|
||||
<span>已发布业务链</span>
|
||||
<select value={selectedChainId} onChange={(event) => setSelectedChainId(event.target.value)}>
|
||||
<option value="">请选择业务链</option>
|
||||
{chains.map((chain) => (
|
||||
<option key={chain.id} value={chain.id}>
|
||||
{chain.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{error && <p className="eightbf-roundtable__settings-error" role="alert">{error}</p>}
|
||||
<div className="eightbf-roundtable__settings-actions">
|
||||
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
|
||||
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
|
||||
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
保存并应用
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundtablePlanningPage
|
||||
compactChrome={compactChrome}
|
||||
lockedChainId={mapping?.chainId ?? null}
|
||||
lockBusinessChain
|
||||
storageTaskId={storageTaskId}
|
||||
toolbarSlot={toolbarSlot}
|
||||
entryRailBusinessChainSlot={
|
||||
<PositionBusinessChainFlow
|
||||
chain={configuredChain}
|
||||
loading={isLoading}
|
||||
className="eightbf-roundtable__business-chain-flow"
|
||||
/>
|
||||
}
|
||||
rootClassName="eightbf-roundtable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* 8BF 第 1 步(任务理解)独立样式入口。所有规则限定在 .eightbf-roundtable 内,不影响原多智能体会商。 */
|
||||
.eightbf-roundtable {
|
||||
--eightbf-accent: #0f766e;
|
||||
--eightbf-accent-soft: #ecfdf5;
|
||||
--eightbf-border: #b7ded8;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-status {
|
||||
display: inline-flex;
|
||||
min-width: 184px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--eightbf-border);
|
||||
border-radius: 10px;
|
||||
background: var(--eightbf-accent-soft);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; }
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
|
||||
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
|
||||
|
||||
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
|
||||
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
|
||||
height: 500px;
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 8BF 第 2 步(多智能体会商)独立样式入口。 */
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-settings-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 9px;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
transition: background-color .15s ease;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
|
||||
|
||||
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 8BF 第 3 步(结果绘制)独立样式入口。 */
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(15 23 42 / 42%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-dialog {
|
||||
width: min(460px, 100%);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
box-shadow: 0 24px 56px rgb(15 23 42 / 22%);
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-heading,
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #0f766e; font-size: 12px; font-weight: 700; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-heading h2 { margin-top: 2px; color: #0f172a; font-size: 16px; font-weight: 700; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-close { color: #64748b; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-tip { margin-top: 14px; color: #64748b; font-size: 13px; line-height: 1.6; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-field { display: grid; gap: 7px; margin-top: 16px; color: #334155; font-size: 13px; font-weight: 600; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-field select { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; font-weight: 500; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-error { margin-top: 10px; color: #b91c1c; font-size: 12px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-actions { margin-top: 20px; justify-content: flex-end; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-actions button { border-radius: 8px; padding: 8px 12px; font-size: 13px; font-weight: 700; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; }
|
||||
.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; }
|
||||
|
||||
/* 管理员业务链配置弹窗:深色主题下与会商工作区使用同一套深蓝表面与高对比文字。 */
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-mask {
|
||||
background: rgb(2 12 30 / 72%);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-dialog {
|
||||
border: 1px solid rgb(69 195 243 / 42%);
|
||||
background: #082b57;
|
||||
box-shadow: 0 24px 56px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #65d6c6; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-heading h2 { color: #f1f7ff; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-close { color: #a9bddb; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-close:hover { color: #fff; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-tip { color: #b9c9df; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-field { color: #e6effc; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select {
|
||||
border-color: rgb(143 196 255 / 48%);
|
||||
background: #0d3768;
|
||||
color: #f1f7ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select:focus {
|
||||
border-color: #65d6c6;
|
||||
outline: 2px solid rgb(101 214 198 / 28%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-field option { background: #0d3768; color: #f1f7ff; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-error { color: #fca5a5; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child {
|
||||
color: #b9c9df;
|
||||
}
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child:hover {
|
||||
background: rgb(143 196 255 / 12%);
|
||||
color: #fff;
|
||||
}
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child {
|
||||
background: #167d74;
|
||||
}
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child:hover {
|
||||
background: #21958a;
|
||||
}
|
||||
|
||||
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
|
||||
@@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean {
|
||||
return readRuntimeEnv(key) === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an integer setting supplied by ``runtime-config.js``.
|
||||
*
|
||||
* Runtime configuration is editable after the frontend is built, so validate
|
||||
* numeric values here instead of letting an invalid deployment value bypass a
|
||||
* UI constraint that the backend cannot accept.
|
||||
*/
|
||||
export const env = {
|
||||
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
|
||||
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
|
||||
@@ -29,5 +36,10 @@ export const env = {
|
||||
NEXT_PUBLIC_NOTE_API_BASE_URL: readRuntimeEnv("VITE_NOTE_API_BASE_URL"),
|
||||
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readRuntimeEnv("VITE_AIPI_BACKENP_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
/**
|
||||
* Deployment-level compact navigation mode. This is intentionally a runtime
|
||||
* flag so operations can switch the shell without rebuilding the frontend.
|
||||
*/
|
||||
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
|
||||
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
@@ -2,9 +2,10 @@ import "@fontsource/noto-sans-sc/chinese-simplified-400.css";
|
||||
import "@fontsource/noto-sans-sc/chinese-simplified-500.css";
|
||||
import "@fontsource/noto-sans-sc/chinese-simplified-600.css";
|
||||
import "@fontsource/noto-sans-sc/chinese-simplified-700.css";
|
||||
import "./styles/globals.css";
|
||||
import "tdesign-react/es/style/index.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./styles/globals.css";
|
||||
import "tdesign-react/es/style/index.css";
|
||||
import "./styles/tdesign-theme.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
@@ -37,6 +37,7 @@ 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 { isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
@@ -100,6 +101,11 @@ export default function ChatPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// `chatMode=full` is used by an embedded standard chat. It intentionally
|
||||
// overrides the user's compact-layout preference for this route only, so
|
||||
// the page keeps the full landing composition and its agent/history sidebar.
|
||||
const fullChatMode =
|
||||
searchParams.get("chatMode")?.trim().toLowerCase() === "full";
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
@@ -451,7 +457,7 @@ export default function ChatPage() {
|
||||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||||
const layoutMode = effectiveLayoutMode;
|
||||
const chromeByPreference =
|
||||
layoutMode === "full"
|
||||
fullChatMode || layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
@@ -459,6 +465,7 @@ export default function ChatPage() {
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
const compactActionsHidden = isHideChromeActive();
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
@@ -483,7 +490,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
{compactChrome && !historySidebarOpen && (
|
||||
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -507,7 +514,7 @@ export default function ChatPage() {
|
||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{!compactChrome && (
|
||||
{!compactChrome && !compactActionsHidden && (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
|
||||
@@ -31,6 +31,7 @@ import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { buildChatsIframeThreadPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
@@ -230,7 +231,11 @@ export default function IframeChatPage() {
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
|
||||
`${window.location.pathname}${window.location.search}#${buildChatsIframeThreadPath(
|
||||
routeBase,
|
||||
createdThreadId,
|
||||
location.search,
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (pendingRequestIdRef.current) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
getNodeTitle,
|
||||
} from "@/components/knowledge-graph/graphUtils";
|
||||
import "@/components/knowledge-graph/graphStyles.css";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
useGraphBlacklist,
|
||||
useGraphBlacklistActions,
|
||||
@@ -799,7 +799,7 @@ export default function KnowledgeGraphPage() {
|
||||
searchMatchIds={searchMatchIds}
|
||||
layoutType={layoutType}
|
||||
showLabels={showLabels}
|
||||
theme={theme}
|
||||
theme={themeToBaseMode(theme)}
|
||||
onSelectNode={handleSelectNode}
|
||||
onTooltip={setTooltip}
|
||||
onZoomChange={setZoomLevel}
|
||||
|
||||
@@ -21,7 +21,17 @@ export default function LightAppIframeView() {
|
||||
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
|
||||
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。
|
||||
const themeColor: AppThemeColor =
|
||||
theme === "dark" ? "dark-blue" : theme === "lightblue" ? "blue" : "red";
|
||||
theme === "dark"
|
||||
? "dark-blue"
|
||||
: theme === "lightgreen"
|
||||
? "green"
|
||||
: theme === "lightblue"
|
||||
? "blue"
|
||||
: theme === "iceblue"
|
||||
? "iceblue"
|
||||
: theme === "lightyellow"
|
||||
? "lightyellow"
|
||||
: "red";
|
||||
const src = useMemo(
|
||||
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
|
||||
[app, themeColor],
|
||||
|
||||
@@ -34,7 +34,8 @@ import { toast } from "sonner";
|
||||
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { isCustomThemeId } from "@/core/theme/custom-theme";
|
||||
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
@@ -64,12 +65,79 @@ function hashIndex(str: string, mod: number): number {
|
||||
return mod > 0 ? h % mod : 0;
|
||||
}
|
||||
|
||||
function getLightAccent(theme: ThemeType) {
|
||||
if (isCustomThemeId(theme)) {
|
||||
return {
|
||||
dot: "bg-[var(--primary)]",
|
||||
badge: "border-[var(--border)] bg-[var(--accent)] text-[var(--accent-foreground)]",
|
||||
focus: "focus:border-[var(--brand-900)] focus:ring-[var(--ring)]",
|
||||
text: "text-[var(--accent-foreground)]",
|
||||
hoverText: "group-hover:text-[var(--accent-foreground)]",
|
||||
link: "text-[var(--accent-foreground)] hover:text-[var(--brand-950)]",
|
||||
button: "bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] shadow-sm hover:bg-[var(--theme-button-hover)]",
|
||||
};
|
||||
}
|
||||
if (theme === "lightgreen") {
|
||||
return {
|
||||
dot: "bg-[#07C05F]",
|
||||
badge: "border-[#07C05F]/15 bg-[#E9F8EC] text-[#049B38]",
|
||||
focus: "focus:border-[#07C05F]/60 focus:ring-[#07C05F]/20",
|
||||
text: "text-[#049B38]",
|
||||
hoverText: "group-hover:text-[#049B38]",
|
||||
link: "text-[#049B38] hover:text-[#038626]",
|
||||
button: "bg-[#07C05F] shadow-[0_1px_3px_rgba(7,192,95,0.2)] hover:bg-[#08DD6E]",
|
||||
};
|
||||
}
|
||||
if (theme === "lightblue") {
|
||||
return {
|
||||
dot: "bg-[#0062D9]",
|
||||
badge: "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]",
|
||||
focus: "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20",
|
||||
text: "text-[#0062D9]",
|
||||
hoverText: "group-hover:text-[#0062D9]",
|
||||
link: "text-[#0062D9] hover:text-[#0056C6]",
|
||||
button: "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]",
|
||||
};
|
||||
}
|
||||
if (theme === "iceblue") {
|
||||
return {
|
||||
dot: "bg-[#9BCEF1]",
|
||||
badge: "border-[#3D6E96]/15 bg-[#E1F1FB] text-[#2D587A]",
|
||||
focus: "focus:border-[#3D6E96]/60 focus:ring-[#3D6E96]/20",
|
||||
text: "text-[#2D587A]",
|
||||
hoverText: "group-hover:text-[#2D587A]",
|
||||
link: "text-[#2D587A] hover:text-[#1E405C]",
|
||||
button: "bg-[#9BCEF1] text-[#18324A] shadow-[0_1px_3px_rgba(155,206,241,0.42)] hover:bg-[#B7DCF5]",
|
||||
};
|
||||
}
|
||||
if (theme === "lightyellow") {
|
||||
return {
|
||||
dot: "bg-[#F3FBD9]",
|
||||
badge: "border-[#4C6330]/15 bg-[#EEF6D4] text-[#4C6330]",
|
||||
focus: "focus:border-[#4C6330]/60 focus:ring-[#F3FBD9]/40",
|
||||
text: "text-[#4C6330]",
|
||||
hoverText: "group-hover:text-[#4C6330]",
|
||||
link: "text-[#4C6330] hover:text-[#34451F]",
|
||||
button: "bg-[#F3FBD9] text-[#303D23] shadow-[0_1px_3px_rgba(243,251,217,0.72)] hover:bg-[#E4EFBF]",
|
||||
};
|
||||
}
|
||||
return {
|
||||
dot: "bg-[#CF1322]",
|
||||
badge: "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]",
|
||||
focus: "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20",
|
||||
text: "text-[#CF1322]",
|
||||
hoverText: "group-hover:text-[#CF1322]",
|
||||
link: "text-[#CF1322] hover:text-[#A8071A]",
|
||||
button: "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]",
|
||||
};
|
||||
}
|
||||
|
||||
export default function LightAppListPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const lightAccent = getLightAccent(theme);
|
||||
|
||||
const { data: apps = [], isLoading } = useLightApps({
|
||||
appType: "window_open",
|
||||
@@ -118,13 +186,13 @@ export default function LightAppListPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="relative flex size-3">
|
||||
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} />
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} />
|
||||
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", lightAccent.dot, "dark:bg-[#1677FF]")} />
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
|
||||
应用列表
|
||||
</h1>
|
||||
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", isLightBlue ? "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]" : "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]")}>
|
||||
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
|
||||
导航工作台
|
||||
</span>
|
||||
</div>
|
||||
@@ -142,7 +210,7 @@ export default function LightAppListPage() {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
|
||||
className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25", isLightBlue ? "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20" : "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20")}
|
||||
className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25", lightAccent.focus)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
@@ -161,13 +229,13 @@ export default function LightAppListPage() {
|
||||
<div className="-mt-2 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]">
|
||||
检索到最终结果:{" "}
|
||||
<span className={cn("font-bold dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>{apps.length}</span> 个关联应用
|
||||
<span className={cn("font-bold dark:text-[#4096FF]", lightAccent.text)}>{apps.length}</span> 个关联应用
|
||||
</div>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", isLightBlue ? "text-[#0062D9] hover:text-[#0056C6]" : "text-[#CF1322] hover:text-[#A8071A]")}
|
||||
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", lightAccent.link)}
|
||||
>
|
||||
重置过滤条件
|
||||
</button>
|
||||
@@ -239,7 +307,7 @@ function AppCard({
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const lightAccent = getLightAccent(theme);
|
||||
const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
|
||||
const cardTheme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)];
|
||||
|
||||
@@ -256,7 +324,7 @@ function AppCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", isLightBlue ? "group-hover:text-[#0062D9]" : "group-hover:text-[#CF1322]")}>
|
||||
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", lightAccent.hoverText)}>
|
||||
{app.name}
|
||||
</h4>
|
||||
|
||||
@@ -270,7 +338,7 @@ function AppCard({
|
||||
<div className="mb-4 rounded-[5px] bg-zinc-50/70 p-3 dark:bg-[#0C1836]">
|
||||
<div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]">
|
||||
<span>拼接后目标路由终端:</span>
|
||||
<span className={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>
|
||||
<span className={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", lightAccent.text)}>
|
||||
自动拼接已激活参
|
||||
</span>
|
||||
</div>
|
||||
@@ -312,7 +380,7 @@ function AppCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]", isLightBlue ? "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]" : "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]")}
|
||||
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:text-white dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
|
||||
>
|
||||
<span>进入应用</span>
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
|
||||
@@ -93,7 +93,7 @@ const newParam = (): LightAppRouteParam => ({
|
||||
paramType: "login",
|
||||
source: "access_token",
|
||||
customValue: "",
|
||||
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
|
||||
themeValue: "red=red;green=green;blue=blue;dark-blue=dark-blue",
|
||||
});
|
||||
|
||||
function formFromApp(app: LightApp): FormState {
|
||||
|
||||
@@ -9,7 +9,9 @@ import { cn } from "@/lib/utils";
|
||||
export default function LightAppNoteQAPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
const isLightGreen = theme === "lightgreen";
|
||||
const isLightYellow = theme === "lightyellow";
|
||||
const [question, setQuestion] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -30,7 +32,9 @@ export default function LightAppNoteQAPage() {
|
||||
style={{
|
||||
background: isDark
|
||||
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
|
||||
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
|
||||
: isLightYellow
|
||||
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(243,251,217,0.7) 0%, rgba(220,235,173,0.26) 50%, transparent 70%)"
|
||||
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -42,7 +46,11 @@ export default function LightAppNoteQAPage() {
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
|
||||
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
|
||||
isDark
|
||||
? "bg-violet-400 text-slate-950"
|
||||
: isLightYellow
|
||||
? "bg-[#4C6330] text-white"
|
||||
: "bg-[#4d3f74] text-white",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="h-6 w-6" />
|
||||
@@ -65,7 +73,9 @@ export default function LightAppNoteQAPage() {
|
||||
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
|
||||
isDark
|
||||
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
|
||||
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
|
||||
: isLightYellow
|
||||
? "bg-[#EEF6D4] text-[#4C6330] hover:bg-[#E4EFBF]"
|
||||
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
@@ -112,7 +122,11 @@ export default function LightAppNoteQAPage() {
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
isDark
|
||||
? "bg-sky-500 text-white hover:bg-sky-400"
|
||||
: isLightBlue
|
||||
: isLightGreen
|
||||
? "bg-[#07C05F] text-white hover:bg-[#08DD6E]"
|
||||
: isLightYellow
|
||||
? "bg-[#F3FBD9] text-[#303D23] hover:bg-[#E4EFBF]"
|
||||
: isLightBlue
|
||||
? "bg-[#0062D9] text-white hover:bg-[#0079FE]"
|
||||
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,12 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { loginByToken, loginByUsername } from '@/core/auth/api';
|
||||
import { setStoredAuth, setStoredUserName } from '@/core/auth';
|
||||
import {
|
||||
getRouteKeyLoginPassword,
|
||||
resolveRouteKey,
|
||||
resolveRouteKeyMapping,
|
||||
ROUTE_KEY_PARAM,
|
||||
} from '@/core/auth/route-key-login';
|
||||
import {
|
||||
persistExternalLoginParams,
|
||||
readExternalLoginParams,
|
||||
@@ -20,6 +26,7 @@ import {
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
import { readEmbedParam } from '@/core/embed/embed-session';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
@@ -27,6 +34,16 @@ import {
|
||||
persistAccessToken,
|
||||
persistToolsToken,
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
buildStandardChatRedirectPath,
|
||||
buildEmbeddedIframeRedirectPath,
|
||||
buildChatsIframeRedirectPath,
|
||||
hasIframePresentationOverride,
|
||||
isChatsIframeLogin,
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolvePostLoginRedirectPath,
|
||||
shouldUseChatsIframeProtocol,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
@@ -66,9 +83,9 @@ function summarizeStoredUserInfo(): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
const IFRAME_CHAT_MODE = 'iframe';
|
||||
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
|
||||
const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new';
|
||||
const FULL_CHAT_MODE = 'full';
|
||||
|
||||
type LoginStatus = 'loading' | 'success' | 'error';
|
||||
|
||||
@@ -89,19 +106,86 @@ export default function LoginPage() {
|
||||
() => readEmbedParam(searchParams.toString()) === 'on',
|
||||
[searchParams],
|
||||
);
|
||||
const redirectPath = useMemo(
|
||||
() =>
|
||||
searchParams.get('from') === IFRAME_LOGIN_SOURCE
|
||||
? IFRAME_REDIRECT_PATH
|
||||
: DEFAULT_REDIRECT_PATH,
|
||||
const requestedChatMode =
|
||||
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
|
||||
const iframePresentationOverride = useMemo(
|
||||
() => hasIframePresentationOverride(searchParams.toString()),
|
||||
[searchParams],
|
||||
);
|
||||
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
|
||||
// `from=iframe` is the established entry contract for the dedicated iframe
|
||||
// page. A standard presentation flag is the one intentional exception: it
|
||||
// uses the normal full chat workspace so Header/PageSidebar visibility is
|
||||
// controlled by hideHeader/hideChrome/embed instead.
|
||||
const brainyIframeRequested = shouldUseChatsIframeProtocol(
|
||||
searchParams.toString(),
|
||||
embeddedHost,
|
||||
);
|
||||
// An explicit full-chat request and all standard presentation flags retain
|
||||
// the regular workspace. Otherwise native iframe/Wujie hosts use the
|
||||
// legacy dedicated AgentSelector entry, matching the established bare
|
||||
// iframe login integration.
|
||||
const fullChatRequested = useMemo(
|
||||
() =>
|
||||
!brainyIframeRequested &&
|
||||
(isWujieEnv() ||
|
||||
requestedChatMode === FULL_CHAT_MODE ||
|
||||
// hideHeader/hideChrome/embed are explicit presentation contracts.
|
||||
// They must retain their query parameters and enter the standard
|
||||
// workspace even when a native iframe has no legacy `from` marker.
|
||||
iframePresentationOverride),
|
||||
[
|
||||
brainyIframeRequested,
|
||||
iframePresentationOverride,
|
||||
requestedChatMode,
|
||||
searchParams,
|
||||
],
|
||||
);
|
||||
const isIframeLogin = useMemo(
|
||||
() => brainyIframeRequested,
|
||||
[brainyIframeRequested],
|
||||
);
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||||
// 外部跳转参数:普通登录的 theme 调 setTheme;iframe 登录由 forcedTheme 接管。
|
||||
// ticket/userId/env 仍按既有登录存储策略持久化。
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const redirectPath = useMemo(
|
||||
() => {
|
||||
// Keep the mode on the destination URL so PageRoutes can retain the
|
||||
// full chat workspace sidebar while hideChrome hides only the global
|
||||
// application chrome.
|
||||
if (brainyIframeRequested) {
|
||||
return !isChatsIframeLogin(searchParams.toString()) &&
|
||||
requestedChatMode !== IFRAME_CHAT_MODE
|
||||
? buildEmbeddedIframeRedirectPath(searchParams.toString())
|
||||
: buildChatsIframeRedirectPath(searchParams.toString());
|
||||
}
|
||||
if (fullChatRequested) {
|
||||
return buildStandardChatRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
}
|
||||
// External iframe callers frequently also provide yUserId for login.
|
||||
// That identity parameter must not steal a deliberate iframe entry and
|
||||
// send it to the generic full-chat home before the route split above.
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
return resolvePostLoginRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
},
|
||||
[
|
||||
brainyIframeRequested,
|
||||
fullChatRequested,
|
||||
requestedChatMode,
|
||||
searchParams,
|
||||
yUserId,
|
||||
],
|
||||
);
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
const access_token = searchParams.get('access_token')?.trim() ?? '';
|
||||
@@ -109,6 +193,18 @@ export default function LoginPage() {
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
|
||||
const routeKeyMapping = useMemo(
|
||||
() => resolveRouteKeyMapping(routeKey),
|
||||
[routeKey],
|
||||
);
|
||||
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
|
||||
const configuredRouteLoginPassword = useMemo(
|
||||
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
|
||||
[routeKeyTarget],
|
||||
);
|
||||
const effectiveGatePassword = configuredRouteLoginPassword || gatePassword;
|
||||
const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword);
|
||||
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
|
||||
const taskId = searchParams.get('taskId')?.trim() ?? '';
|
||||
const goPath = searchParams.get('goPath')?.trim() ?? '';
|
||||
@@ -123,16 +219,37 @@ export default function LoginPage() {
|
||||
// taskId,无需把它编进 redirect 值里。目标已自带的同名参数优先(不覆盖);登录鉴权类参数
|
||||
// (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。
|
||||
const redirectTo = useMemo(() => {
|
||||
const raw = searchParams.get('redirect')?.trim() ?? '';
|
||||
const raw =
|
||||
routeKeyTarget ||
|
||||
searchParams.get('redirect')?.trim() ||
|
||||
searchParams.get('route')?.trim() ||
|
||||
'';
|
||||
if (!raw) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//') || raw.includes('\\')) return '';
|
||||
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
|
||||
const [pathOnly, ownQuery = ''] = normalized.split('?');
|
||||
const target = new URLSearchParams(ownQuery);
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
pathOnly,
|
||||
searchParams.toString(),
|
||||
embeddedHost,
|
||||
);
|
||||
if (iframeEntryTarget) {
|
||||
return iframeEntryTarget;
|
||||
}
|
||||
// Keep the Wujie/full-chat decision on the target URL. The destination
|
||||
// route uses it to retain the full agent/history workspace sidebar.
|
||||
if (
|
||||
fullChatRequested &&
|
||||
pathOnly === DEFAULT_REDIRECT_PATH &&
|
||||
!target.has('chatMode')
|
||||
) {
|
||||
target.set('chatMode', FULL_CHAT_MODE);
|
||||
}
|
||||
// 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。
|
||||
const LOGIN_OWNED = new Set([
|
||||
'redirect', 'ticket', 'userId', 'env', 'theme', 'yUserId',
|
||||
'token', 'access_token', 'password', 'authToken', 'goPath', 'from',
|
||||
'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId',
|
||||
'token', 'access_token', 'password', 'authToken', 'goPath', 'from', ROUTE_KEY_PARAM,
|
||||
]);
|
||||
new URLSearchParams(searchParams.toString()).forEach((value, key) => {
|
||||
if (LOGIN_OWNED.has(key)) return;
|
||||
@@ -141,7 +258,7 @@ export default function LoginPage() {
|
||||
});
|
||||
const qs = target.toString();
|
||||
return qs ? `${pathOnly}?${qs}` : pathOnly;
|
||||
}, [searchParams]);
|
||||
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
@@ -176,14 +293,24 @@ export default function LoginPage() {
|
||||
|
||||
// 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致
|
||||
// (dark-blue → 深色;red → 浅色;blue 暂无该主题,先用浅色代替),统一走 resolveThemeFromQuery。
|
||||
// **嵌入模式跳过**:setTheme 会写 localStorage `strategy-theme`,跨标签污染正常系统页;
|
||||
// 嵌入主题改由 forcedTheme(按标签页、不持久化)接管。
|
||||
// **嵌入模式和 chats_iframe 登录跳过**:setTheme 会写 localStorage `strategy-theme`,
|
||||
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
|
||||
useEffect(() => {
|
||||
if (isEmbed) return;
|
||||
const themeValue = wujieProps.theme || externalParams.theme;
|
||||
if (isEmbed || isIframeLogin) return;
|
||||
// routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼
|
||||
// `theme=dark-blue` 也能在登录完成前切换到指定主题。
|
||||
const themeValue =
|
||||
routeKeyMapping?.theme || wujieProps.theme || externalParams.theme;
|
||||
if (!themeValue) return;
|
||||
setTheme(resolveThemeFromQuery(themeValue));
|
||||
}, [isEmbed, wujieProps.theme, externalParams.theme, setTheme]);
|
||||
}, [
|
||||
isEmbed,
|
||||
isIframeLogin,
|
||||
routeKeyMapping?.theme,
|
||||
wujieProps.theme,
|
||||
externalParams.theme,
|
||||
setTheme,
|
||||
]);
|
||||
|
||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||
useEffect(() => {
|
||||
@@ -220,7 +347,9 @@ export default function LoginPage() {
|
||||
access_token: maskLoginToken(access_token),
|
||||
toolToken: maskLoginToken(toolToken),
|
||||
authToken: maskLoginToken(authToken),
|
||||
gatePassword: gatePassword ? maskLoginToken(gatePassword) : '',
|
||||
routeKey,
|
||||
configuredRouteLoginPassword: usesConfiguredRouteLoginPassword,
|
||||
gatePassword: effectiveGatePassword ? maskLoginToken(effectiveGatePassword) : '',
|
||||
onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '',
|
||||
taskId,
|
||||
goPath,
|
||||
@@ -265,7 +394,7 @@ export default function LoginPage() {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||||
auth = await loginByUsername(deerflowUsername, effectiveGatePassword);
|
||||
loginUserName = deerflowUsername;
|
||||
}
|
||||
if (cancelled) {
|
||||
@@ -341,7 +470,7 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
router.replace(redirectPath);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -367,10 +496,14 @@ export default function LoginPage() {
|
||||
access_token,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
effectiveGatePassword,
|
||||
usesConfiguredRouteLoginPassword,
|
||||
routeKey,
|
||||
gatePassword,
|
||||
taskId,
|
||||
goPath,
|
||||
redirectTo,
|
||||
redirectPath,
|
||||
isEmbed,
|
||||
]);
|
||||
|
||||
|
||||
@@ -13,6 +13,21 @@ import {
|
||||
useUpdateModelConcurrencySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
function cleanLimitMap(values: Record<string, string>, label: string): Record<string, number> | null {
|
||||
const cleaned: Record<string, number> = {};
|
||||
for (const [name, rawValue] of Object.entries(values)) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) continue;
|
||||
const parsed = Math.round(Number(trimmed));
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
toast.error(`${label}「${name}」必须是大于 0 的整数`);
|
||||
return null;
|
||||
}
|
||||
cleaned[name] = Math.min(parsed, 10000);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export default function ModelConcurrencySettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
@@ -24,13 +39,16 @@ export default function ModelConcurrencySettingsPage({
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [defaultLimit, setDefaultLimit] = useState(10);
|
||||
const [userDefaultLimit, setUserDefaultLimit] = useState(0);
|
||||
const [modelLimits, setModelLimits] = useState<Record<string, string>>({});
|
||||
const [userModelLimits, setUserModelLimits] = useState<Record<string, string>>({});
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
setDefaultLimit(data.default_limit);
|
||||
setUserDefaultLimit(data.user_default_limit ?? 0);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.model_limits ?? {}).map(([name, limit]) => [
|
||||
@@ -39,39 +57,45 @@ export default function ModelConcurrencySettingsPage({
|
||||
]),
|
||||
),
|
||||
);
|
||||
setUserModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.user_model_limits ?? {}).map(([name, limit]) => [
|
||||
name,
|
||||
String(limit),
|
||||
]),
|
||||
),
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const modelNames = useMemo(() => models.map((model) => model.name), [models]);
|
||||
|
||||
const handleModelLimitChange = (modelName: string, value: string) => {
|
||||
setModelLimits((prev) => ({ ...prev, [modelName]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const nextDefault = Math.max(1, Math.min(Math.round(defaultLimit) || 10, 10000));
|
||||
const cleaned: Record<string, number> = {};
|
||||
for (const [name, rawValue] of Object.entries(modelLimits)) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) continue;
|
||||
const parsed = Math.round(Number(trimmed));
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
toast.error(`模型 ${name} 的并发上限必须是大于 0 的整数`);
|
||||
return;
|
||||
}
|
||||
cleaned[name] = Math.min(parsed, 10000);
|
||||
}
|
||||
const nextUserDefault = Math.max(0, Math.min(Math.round(userDefaultLimit) || 0, 10000));
|
||||
const cleanedModelLimits = cleanLimitMap(modelLimits, "模型总上限");
|
||||
if (!cleanedModelLimits) return;
|
||||
const cleanedUserModelLimits = cleanLimitMap(userModelLimits, "单用户上限");
|
||||
if (!cleanedUserModelLimits) return;
|
||||
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
default_limit: nextDefault,
|
||||
model_limits: cleaned,
|
||||
model_limits: cleanedModelLimits,
|
||||
user_default_limit: nextUserDefault,
|
||||
user_model_limits: cleanedUserModelLimits,
|
||||
});
|
||||
setDefaultLimit(nextDefault);
|
||||
setUserDefaultLimit(nextUserDefault);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(cleaned).map(([name, limit]) => [name, String(limit)]),
|
||||
Object.entries(cleanedModelLimits).map(([name, limit]) => [name, String(limit)]),
|
||||
),
|
||||
);
|
||||
setUserModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(cleanedUserModelLimits).map(([name, limit]) => [name, String(limit)]),
|
||||
),
|
||||
);
|
||||
toast.success("已保存");
|
||||
@@ -85,7 +109,7 @@ export default function ModelConcurrencySettingsPage({
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 overflow-y-auto px-6 py-10"
|
||||
: "mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col gap-6 overflow-y-auto px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -93,7 +117,7 @@ export default function ModelConcurrencySettingsPage({
|
||||
模型并发限制
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前使用人数较多,请稍等一下再试”的提示。
|
||||
按模型控制系统总并发,也可以限制单个用户对某个模型同时发起的调用数。达到上限时会在发送问题后立即提示,问题会保留在对话中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -102,42 +126,59 @@ export default function ModelConcurrencySettingsPage({
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
<div className="text-destructive rounded-lg border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex flex-col gap-5 rounded-lg border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">开启限制</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,不再按模型并发数拦截问答请求。
|
||||
关闭后,不再按模型或用户并发数拦截问答请求。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">模型默认总上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个系统总并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">单用户默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
同一用户对同一模型的默认并发上限。填 0 表示不启用默认用户限制。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
value={userDefaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setUserDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">按模型单独设置</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
留空表示使用默认上限。
|
||||
留空表示使用对应默认值;单用户上限填空且默认值为 0 时,不限制该维度。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -150,24 +191,42 @@ export default function ModelConcurrencySettingsPage({
|
||||
modelNames.map((modelName) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3"
|
||||
className="grid gap-3 rounded-lg border px-4 py-3 md:grid-cols-[minmax(0,1fr)_8rem_8rem]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{modelName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
留空时使用默认上限 {defaultLimit || 10}
|
||||
默认总上限 {defaultLimit || 10};默认单用户上限 {userDefaultLimit || "不限制"}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => handleModelLimitChange(modelName, e.target.value)}
|
||||
className="w-32"
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">模型总上限</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) =>
|
||||
setModelLimits((prev) => ({ ...prev, [modelName]: e.target.value }))
|
||||
}
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">单用户上限</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={userModelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) =>
|
||||
setUserModelLimits((prev) => ({ ...prev, [modelName]: e.target.value }))
|
||||
}
|
||||
placeholder={userDefaultLimit > 0 ? String(userDefaultLimit) : "不限制"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
InfoIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -38,6 +36,7 @@ import {
|
||||
type SetupAgentApprovalPayload,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
@@ -51,7 +50,7 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
|
||||
import { isAgentSkillCandidate } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@@ -98,7 +97,6 @@ export default function NewAgentPage() {
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
const [skillSearch, setSkillSearch] = useState("");
|
||||
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
|
||||
const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId);
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
@@ -123,17 +121,6 @@ export default function NewAgentPage() {
|
||||
[skills, storedAuth],
|
||||
);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const q = skillSearch.trim().toLowerCase();
|
||||
if (!q) return enabledSkills;
|
||||
return enabledSkills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [enabledSkills, skillSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSkillSelectionTouched) {
|
||||
return;
|
||||
@@ -380,14 +367,10 @@ export default function NewAgentPage() {
|
||||
[handleChatSubmit],
|
||||
);
|
||||
|
||||
const toggleSkill = useCallback((skill: Skill) => {
|
||||
const handleSkillSelectionChange = useCallback((next: string[]) => {
|
||||
setIsSkillSelectionTouched(true);
|
||||
setFormError("");
|
||||
setSelectedSkills((prev) =>
|
||||
prev.includes(skill.name)
|
||||
? prev.filter((name) => name !== skill.name)
|
||||
: [...prev, skill.name],
|
||||
);
|
||||
setSelectedSkills(next);
|
||||
}, []);
|
||||
|
||||
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
|
||||
@@ -624,68 +607,13 @@ export default function NewAgentPage() {
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{enabledSkills.length > 0 && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能名称或描述…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可用技能。
|
||||
</p>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
没有匹配的技能。
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill)}
|
||||
title={skill.description}
|
||||
className={cn(
|
||||
"h-8 min-w-0 flex-1 rounded-r-none",
|
||||
!checked && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{skill.name_zh?.trim() ? skill.name_zh : skill.name}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => setDetailSkillName(skill.name)}
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
className="h-8 w-7 rounded-l-none border-l-0"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkillTransferSelector
|
||||
skills={enabledSkills}
|
||||
selected={selectedSkills}
|
||||
onChange={handleSkillSelectionChange}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SkillDetailDialog
|
||||
|
||||
@@ -4,7 +4,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
|
||||
|
||||
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue'];
|
||||
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen', 'iceblue', 'lightyellow'];
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
|
||||
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
NOTIFICATION_TEMPLATE_VARIABLES,
|
||||
NotificationTemplateConflictError,
|
||||
previewNotificationTemplate,
|
||||
useNotificationTemplateActions,
|
||||
useNotificationTemplates,
|
||||
type NotificationTemplate,
|
||||
type NotificationTemplateInput,
|
||||
type PreviewResult,
|
||||
} from "@/strategy-components/api/notification-templates";
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tagsText: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ConflictState {
|
||||
server: NotificationTemplate;
|
||||
mine: FormState;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
titleTemplate: "",
|
||||
contentTemplate: "",
|
||||
tagsText: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function formFromTemplate(t: NotificationTemplate): FormState {
|
||||
return {
|
||||
name: t.name,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
tagsText: (t.tags ?? []).join(", "),
|
||||
enabled: t.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of text.split(/[,,]/)) {
|
||||
const tag = part.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toInput(form: FormState): NotificationTemplateInput {
|
||||
return {
|
||||
name: form.name,
|
||||
titleTemplate: form.titleTemplate,
|
||||
contentTemplate: form.contentTemplate,
|
||||
tags: parseTags(form.tagsText),
|
||||
enabled: form.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
|
||||
userName: "张三",
|
||||
taskName: "示例任务",
|
||||
taskId: "T-001",
|
||||
dueDate: "2026-08-10",
|
||||
};
|
||||
|
||||
export default function NotificationTemplatesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const actions = useNotificationTemplateActions();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [keywordApplied, setKeywordApplied] = useState("");
|
||||
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
keyword: keywordApplied || undefined,
|
||||
enabled:
|
||||
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
|
||||
tag: tagFilter.trim() || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useNotificationTemplates(listParams);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
|
||||
|
||||
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const saving =
|
||||
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
|
||||
|
||||
// 实时预览(编辑弹窗打开时)
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
void previewNotificationTemplate({
|
||||
titleTemplate: form.titleTemplate,
|
||||
contentTemplate: form.contentTemplate,
|
||||
variables: previewVars,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPreview(r);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPreview(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
}, 280);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (t: NotificationTemplate) => {
|
||||
setEditing(t);
|
||||
setForm(formFromTemplate(t));
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const applySearch = () => {
|
||||
setPage(1);
|
||||
setKeywordApplied(keyword.trim());
|
||||
};
|
||||
|
||||
const handleSave = async (opts?: { forceVersion?: number }) => {
|
||||
setSaveError(null);
|
||||
const input = toInput(form);
|
||||
try {
|
||||
if (editing) {
|
||||
const version = opts?.forceVersion ?? editing.version;
|
||||
const updated = await actions.update.mutateAsync({
|
||||
id: editing.id,
|
||||
input: { ...input, expectedVersion: version },
|
||||
});
|
||||
setEditing(updated);
|
||||
setConflict(null);
|
||||
toast.success("模板已保存");
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
await actions.create.mutateAsync(input);
|
||||
toast.success("模板已创建");
|
||||
setDialogOpen(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof NotificationTemplateConflictError) {
|
||||
setConflict({ server: e.server, mine: form });
|
||||
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
setSaveError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReloadFromServer = () => {
|
||||
if (!conflict) return;
|
||||
setEditing(conflict.server);
|
||||
setForm(formFromTemplate(conflict.server));
|
||||
setConflict(null);
|
||||
setSaveError(null);
|
||||
toast.info("已加载服务器最新版本");
|
||||
};
|
||||
|
||||
const handleForceOverwrite = async () => {
|
||||
if (!conflict) return;
|
||||
await handleSave({ forceVersion: conflict.server.version });
|
||||
};
|
||||
|
||||
const handleToggle = async (t: NotificationTemplate) => {
|
||||
try {
|
||||
await actions.update.mutateAsync({
|
||||
id: t.id,
|
||||
input: {
|
||||
name: t.name,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
tags: t.tags,
|
||||
enabled: !t.enabled,
|
||||
expectedVersion: t.version,
|
||||
},
|
||||
});
|
||||
toast.success(t.enabled ? "已停用" : "已启用");
|
||||
} catch (e) {
|
||||
if (e instanceof NotificationTemplateConflictError) {
|
||||
toast.error("版本冲突,请刷新列表后重试");
|
||||
void refetch();
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof Error ? e.message : "启停失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (t: NotificationTemplate) => {
|
||||
try {
|
||||
await actions.duplicate.mutateAsync(t.id);
|
||||
toast.success("已复制");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(deleteTarget.id);
|
||||
toast.success("已删除");
|
||||
setDeleteTarget(null);
|
||||
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
|
||||
const token = `{{${name}}}`;
|
||||
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
||||
<FileText className="size-5" />
|
||||
通知模板管理
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
维护消息通知的标题/正文模板。支持变量:
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||
{`{{${v}}}`}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
新建模板
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder="搜索名称 / 标题 / 正文"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" onClick={applySearch}>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onValueChange={(v) => {
|
||||
setEnabledFilter(v as "all" | "true" | "false");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="true">已启用</SelectItem>
|
||||
<SelectItem value="false">已停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
|
||||
<Input
|
||||
value={tagFilter}
|
||||
placeholder="精确匹配标签"
|
||||
onChange={(e) => {
|
||||
setTagFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
|
||||
? "无权限访问通知模板管理"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "加载失败"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
|
||||
<FileText className="size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">暂无通知模板</p>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
创建第一个模板
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">名称</th>
|
||||
<th className="px-3 py-2 font-medium">标题模板</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<th className="px-3 py-2 font-medium">版本</th>
|
||||
<th className="px-3 py-2 font-medium">启用</th>
|
||||
<th className="px-3 py-2 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((t) => (
|
||||
<tr key={t.id} className="border-t">
|
||||
<td className="px-3 py-2.5 font-medium">{t.name}</td>
|
||||
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
|
||||
{t.titleTemplate}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.tags.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
t.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(t)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleDuplicate(t)}
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteTarget(t)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建 / 编辑 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
maxLength={50}
|
||||
placeholder="2~50 个字符"
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
|
||||
<Input
|
||||
value={form.titleTemplate}
|
||||
maxLength={120}
|
||||
placeholder="如:任务 {{taskName}} 即将到期"
|
||||
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => insertVariable(v, "titleTemplate")}
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
|
||||
<Textarea
|
||||
value={form.contentTemplate}
|
||||
rows={7}
|
||||
maxLength={5000}
|
||||
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
|
||||
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => insertVariable(v, "contentTemplate")}
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
标签(逗号分隔,最多 5 个)
|
||||
</label>
|
||||
<Input
|
||||
value={form.tagsText}
|
||||
placeholder="提醒, 任务"
|
||||
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
/>
|
||||
<span className="text-sm">启用</span>
|
||||
{editing && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
当前编辑版本 v{editing.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div className="text-sm font-medium">实时预览</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<div key={v}>
|
||||
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
|
||||
<Input
|
||||
value={previewVars[v] ?? ""}
|
||||
onChange={(e) =>
|
||||
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded border bg-background p-3 text-sm">
|
||||
{previewLoading && !preview ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
预览中…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
|
||||
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||
{preview?.content || "(正文预览)"}
|
||||
</div>
|
||||
{preview && preview.missingVariables.length > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="size-4" />
|
||||
版本冲突 — 请选择如何处理
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded border bg-background p-2 text-xs">
|
||||
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
|
||||
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
|
||||
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
|
||||
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||
{conflict.server.contentTemplate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border bg-background p-2 text-xs">
|
||||
<div className="mb-1 font-medium">我的编辑内容</div>
|
||||
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
|
||||
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
|
||||
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||
{conflict.mine.contentTemplate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleReloadFromServer}>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
|
||||
覆盖保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
|
||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={actions.remove.isPending}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,13 +9,17 @@ import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import {
|
||||
isEmbedActive,
|
||||
isChromeHidden,
|
||||
isHeaderHidden,
|
||||
isHideChromeActive,
|
||||
readEmbedParam,
|
||||
readHideHeaderParam,
|
||||
readHideChromeParam,
|
||||
} from "@/core/embed/embed-session";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { env } from "@/env";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
@@ -27,6 +31,7 @@ import { RoundtableJobNotifier } from "@/roundtable-planning/components/Roundtab
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR;
|
||||
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
|
||||
const location = useLocation();
|
||||
const { theme } = useTheme();
|
||||
@@ -42,7 +47,13 @@ function PageLayout() {
|
||||
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
|
||||
const [layoutMode] = useLayoutMode();
|
||||
const chromeByPreference =
|
||||
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled;
|
||||
forceCollapsedSidebar
|
||||
? false
|
||||
: layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: sidebarEnabled;
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
|
||||
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
|
||||
const embedActive = useMemo(
|
||||
@@ -56,11 +67,35 @@ function PageLayout() {
|
||||
() => isChromeHidden() || readHideChromeParam(location.search) === "on",
|
||||
[location.pathname, location.search],
|
||||
);
|
||||
const hideChromeActive = useMemo(
|
||||
() => isHideChromeActive() || readHideChromeParam(location.search) === "on",
|
||||
[location.pathname, location.search],
|
||||
);
|
||||
// `?hideHeader=1` only removes the global top navigation. Unlike
|
||||
// `hideChrome=1`, PageSidebar remains available in the full layout.
|
||||
const headerHidden = useMemo(
|
||||
() => isHeaderHidden() || readHideHeaderParam(location.search) === "on",
|
||||
[location.pathname, location.search],
|
||||
);
|
||||
// `chatMode=full` is the embed-friendly standard chat view: hide the global
|
||||
// Header/PageSidebar when `hideChrome=1`, but keep WorkspaceSidebar so users
|
||||
// can still pick an agent and access their common agents/history.
|
||||
const fullChatMode = useMemo(() => {
|
||||
const mode = new URLSearchParams(location.search)
|
||||
.get("chatMode")
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
return (
|
||||
mode === "full" &&
|
||||
location.pathname.startsWith("/page/workspace/chats/")
|
||||
);
|
||||
}, [location.pathname, location.search]);
|
||||
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
|
||||
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
|
||||
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
|
||||
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
|
||||
const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent");
|
||||
const forceChromeForDashboardAgent =
|
||||
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
|
||||
const pageSidebarAllowed =
|
||||
(forceChromeForDashboardAgent || chromeByPreference) &&
|
||||
!isChatsIframeWorkspacePath(location.pathname) &&
|
||||
@@ -74,18 +109,20 @@ function PageLayout() {
|
||||
return (
|
||||
<RoundtableJobNotifier>
|
||||
<div className="h-full flex flex-col bg-muted/20">
|
||||
{pageSidebarAllowed && <Header collapsed={collapsed} />}
|
||||
{pageSidebarAllowed && !headerHidden && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{pageSidebarAllowed && (
|
||||
isDark ? (
|
||||
<PageSidebarDark
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
headerHidden={headerHidden}
|
||||
/>
|
||||
) : (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
headerHidden={headerHidden}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -94,9 +131,7 @@ function PageLayout() {
|
||||
!pageSidebarAllowed
|
||||
? ""
|
||||
: isDark
|
||||
// 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起 = 0 sidebar 完全消失,
|
||||
// 只剩左下角浮动按钮(fixed,叠在 main 上不影响布局)。
|
||||
// 不加 transition:transition + sidebar 立刻 unmount 不同步会抽搐
|
||||
// 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起时主内容不留左内边距。
|
||||
? (collapsed ? "pl-0" : "pl-[118px]")
|
||||
: lightNarrowSidebar
|
||||
// 浅色新版窄栏:展开三级列时 96→236px,主内容左内边距随之过渡
|
||||
@@ -113,9 +148,17 @@ function PageLayout() {
|
||||
<WorkspaceRoutes
|
||||
pageSidebarCollapsed={collapsed}
|
||||
pageSidebarHidden={!pageSidebarAllowed}
|
||||
headerHidden={!pageSidebarAllowed}
|
||||
compactWorkspaceSidebar={pageSidebarAllowed}
|
||||
workspaceSidebarHidden={hideChromeActive && !fullChatMode}
|
||||
headerHidden={!pageSidebarAllowed || headerHidden}
|
||||
// Full-chat embedding has no global PageSidebar, but it
|
||||
// still needs the full GeneralQaSidebar (common agents and
|
||||
// conversation history) inside the workspace.
|
||||
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode}
|
||||
compactActionsHidden={hideChromeActive || headerHidden}
|
||||
pageSidebarDark={isDark}
|
||||
forceWorkspaceSidebarCollapsed={
|
||||
forceCollapsedSidebar && !fullChatMode
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 岗位角色配置(仅管理员)。
|
||||
*
|
||||
* 管理岗位会商使用的“工作视角”而非组织岗位/RBAC:一个启用的意图识别岗
|
||||
* 承担第一阶段意图确认,并承载方案总结、行动规划两个收口智能体入口。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bot,
|
||||
BriefcaseBusiness,
|
||||
CircleDot,
|
||||
Info,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type PositionRole,
|
||||
type PositionRoleType,
|
||||
usePositionRoleActions,
|
||||
usePositionRoles,
|
||||
} from "@/strategy-components/api/position-roles";
|
||||
|
||||
let sequence = 0;
|
||||
const newRoleId = () => `custom-${Date.now()}-${++sequence}`;
|
||||
const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
||||
|
||||
function serialize(rows: PositionRole[]): string {
|
||||
return JSON.stringify(
|
||||
rows.map((row, index) => ({
|
||||
...row,
|
||||
sortOrder: index,
|
||||
name: row.name.trim(),
|
||||
description: row.description.trim(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function makeNewRole(sortOrder: number): PositionRole {
|
||||
return {
|
||||
id: newRoleId(),
|
||||
name: "新岗位",
|
||||
description: "请填写该岗位在会商流程中的职责",
|
||||
roleType: "standard",
|
||||
enabled: true,
|
||||
isBuiltin: false,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PositionRolesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: serverRows = [], isLoading, isError, error, refetch } = usePositionRoles();
|
||||
const { save } = usePositionRoleActions();
|
||||
const [rows, setRows] = useState<PositionRole[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
|
||||
const sourceKey = useMemo(() => serialize(serverRows), [serverRows]);
|
||||
useEffect(() => {
|
||||
if (sourceKey !== loadedKey) {
|
||||
setRows(serverRows.map((row, index) => ({ ...row, sortOrder: index })));
|
||||
setLoadedKey(sourceKey);
|
||||
}
|
||||
}, [loadedKey, serverRows, sourceKey]);
|
||||
|
||||
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
|
||||
const enabledIntentId = rows.find((row) => row.enabled && row.roleType === "intent")?.id;
|
||||
const usingFallback = serverRows.some((row) => row.isFallback);
|
||||
const enabledCount = rows.filter((row) => row.enabled).length;
|
||||
const intentRole = rows.find((row) => row.id === enabledIntentId);
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
const update = (id: string, patch: Partial<PositionRole>) =>
|
||||
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
|
||||
const changeRoleType = (id: string, roleType: PositionRoleType) => {
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.id === id
|
||||
? { ...row, roleType, enabled: roleType === "intent" ? true : row.enabled }
|
||||
: roleType === "intent" && row.roleType === "intent"
|
||||
? { ...row, roleType: "standard" }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnabled = (id: string, enabled: boolean) => {
|
||||
const target = rows.find((row) => row.id === id);
|
||||
if (!enabled && target?.roleType === "intent" && enabledIntentId === id) {
|
||||
toast.info("请先将另一个岗位设为“意图识别岗”,再停用当前岗位");
|
||||
return;
|
||||
}
|
||||
update(id, { enabled });
|
||||
};
|
||||
|
||||
const move = (id: string, direction: "up" | "down") => {
|
||||
const index = rows.findIndex((row) => row.id === id);
|
||||
const nextIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= rows.length) return;
|
||||
const next = [...rows];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
setRows(next.map((row, order) => ({ ...row, sortOrder: order })));
|
||||
};
|
||||
|
||||
const remove = (row: PositionRole) => {
|
||||
if (row.isBuiltin) {
|
||||
toast.info("内置岗位为兼容历史业务链而保留;如不再使用可先停用它");
|
||||
return;
|
||||
}
|
||||
if (row.id === enabledIntentId) {
|
||||
toast.info("请先将另一个岗位设为“意图识别岗”后再删除");
|
||||
return;
|
||||
}
|
||||
setRows((current) => current.filter((item) => item.id !== row.id).map((item, index) => ({ ...item, sortOrder: index })));
|
||||
};
|
||||
|
||||
const validate = (): PositionRole[] | null => {
|
||||
if (!rows.length) {
|
||||
toast.error("至少需要保留一个岗位");
|
||||
return null;
|
||||
}
|
||||
const normalized = rows.map((row, sortOrder) => ({
|
||||
...row,
|
||||
id: row.id.trim(),
|
||||
name: row.name.trim(),
|
||||
description: row.description.trim(),
|
||||
sortOrder,
|
||||
}));
|
||||
if (normalized.some((row) => !ID_PATTERN.test(row.id))) {
|
||||
toast.error("岗位标识须以字母开头,只能包含字母、数字、- 或 _");
|
||||
return null;
|
||||
}
|
||||
if (normalized.some((row) => !row.name)) {
|
||||
toast.error("请填写每个岗位的名称");
|
||||
return null;
|
||||
}
|
||||
if (new Set(normalized.map((row) => row.id)).size !== normalized.length) {
|
||||
toast.error("岗位标识不能重复");
|
||||
return null;
|
||||
}
|
||||
if (new Set(normalized.map((row) => row.name.toLocaleLowerCase())).size !== normalized.length) {
|
||||
toast.error("岗位名称不能重复");
|
||||
return null;
|
||||
}
|
||||
if (normalized.filter((row) => row.enabled && row.roleType === "intent").length !== 1) {
|
||||
toast.error("请保留且仅保留一个启用的意图识别岗");
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = validate();
|
||||
if (!payload) return;
|
||||
try {
|
||||
await save.mutateAsync(payload);
|
||||
setRows(payload);
|
||||
toast.success("岗位角色配置已保存,会商页面将按新配置显示");
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "保存岗位角色配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<header className="border-border/70 flex flex-wrap items-center justify-between gap-4 border-b bg-gradient-to-r from-[var(--color-main,#0062D9)]/[0.08] via-background to-background px-6 py-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-main,#0062D9)]/10 text-[var(--color-main,#0062D9)]">
|
||||
<BriefcaseBusiness className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-foreground text-lg font-semibold tracking-tight">岗位角色配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理岗位多智能体会商中的岗位名称、职责、性质、启停与顺序;不影响组织岗位、成员或权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setRows((current) => [...current, makeNewRole(current.length)])}>
|
||||
<Plus className="h-4 w-4" /> 添加岗位
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending || isLoading}>
|
||||
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{save.isPending ? "保存中…" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
|
||||
<section className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ListChecks className="size-4 text-[var(--color-main,#0062D9)]" />已启用岗位</div>
|
||||
<div className="mt-2 flex items-baseline gap-1"><strong className="text-2xl font-semibold text-foreground">{enabledCount}</strong><span className="text-sm text-muted-foreground">个</span></div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--color-main,#0062D9)]/30 bg-[var(--color-main,#0062D9)]/[0.05] px-4 py-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[var(--color-main,#0062D9)]"><CircleDot className="size-4" />会商入口岗位</div>
|
||||
<div className="mt-2 truncate text-base font-semibold text-foreground">{intentRole?.name ?? "尚未配置"}</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">意图识别与收口智能体入口</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ShieldCheck className="size-4 text-emerald-600 dark:text-emerald-400" />内置岗位</div>
|
||||
<div className="mt-2 text-base font-semibold text-foreground">{rows.filter((row) => row.isBuiltin).length} 个稳定席位</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">可改名、调整性质或停用,不可删除</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{usingFallback && (
|
||||
<section className="flex items-start justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
|
||||
<div className="flex gap-2.5 text-amber-900 dark:text-amber-200">
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
<p>当前显示的是内置岗位兜底数据。后端接口尚未返回目录;待后端升级并启动后,系统会自动把这四个岗位写入数据库。</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void refetch()}>重新连接</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="border-border/70 bg-muted/35 flex gap-3 rounded-md border px-4 py-3 text-sm">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-main,#0062D9)]" />
|
||||
<div className="text-muted-foreground leading-6">
|
||||
<span className="text-foreground font-medium">意图识别岗</span>
|
||||
是会商入口:它负责任务意图识别,并显示“方案总结”“行动规划”两个收口智能体。系统必须保留且仅保留一个启用的意图识别岗。
|
||||
内置岗位的稳定标识用于兼容历史业务链,不能删除,但可改名、调整性质或停用。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed py-14 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在加载岗位角色配置…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed py-14 text-center text-sm">
|
||||
<p className="text-destructive">{error instanceof Error ? error.message : "岗位角色配置加载失败"}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>重新加载</Button>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-md border border-border/70 bg-card p-4">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-2 border-b border-border/70 pb-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground"><Bot className="size-4 text-[var(--color-main,#0062D9)]" />岗位目录</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">拖动前可先用上下箭头调整顺序;保存后会立即影响岗位会商的选择与流程。</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">共 {rows.length} 个岗位</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{rows.map((row, index) => (
|
||||
<article key={row.id} className={cn("grid grid-cols-[2.5rem_minmax(0,1fr)_11rem_8rem_auto] items-start gap-4 rounded-md border p-4 transition-colors max-lg:grid-cols-[2.5rem_minmax(0,1fr)_11rem] max-md:grid-cols-1 max-md:gap-3", row.roleType === "intent" ? "border-[var(--color-main,#0062D9)]/45 bg-[var(--color-main,#0062D9)]/[0.045]" : "border-border/70 bg-background hover:border-[var(--color-main,#0062D9)]/30")}>
|
||||
<div className={cn("flex size-10 items-center justify-center rounded-md", row.roleType === "intent" ? "bg-[var(--color-main,#0062D9)]/15 text-[var(--color-main,#0062D9)]" : "bg-muted text-muted-foreground")}>
|
||||
{row.roleType === "intent" ? <CircleDot className="size-5" /> : <BriefcaseBusiness className="size-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`role-name-${row.id}`}>岗位名称</label>
|
||||
<Input
|
||||
id={`role-name-${row.id}`}
|
||||
value={row.name}
|
||||
onChange={(event) => update(row.id, { name: event.target.value })}
|
||||
maxLength={128}
|
||||
className="font-medium"
|
||||
placeholder="岗位名称"
|
||||
/>
|
||||
{row.isBuiltin && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">内置</span>}
|
||||
</div>
|
||||
<label className="sr-only" htmlFor={`role-description-${row.id}`}>岗位职责说明</label>
|
||||
<Input
|
||||
id={`role-description-${row.id}`}
|
||||
value={row.description}
|
||||
onChange={(event) => update(row.id, { description: event.target.value })}
|
||||
maxLength={512}
|
||||
className="text-sm"
|
||||
placeholder="该岗位在会商流程中的职责说明"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="shrink-0">岗位标识:</span>
|
||||
{row.isBuiltin ? (
|
||||
<code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px]">{row.id}</code>
|
||||
) : (
|
||||
<Input
|
||||
aria-label={`${row.name || "新岗位"}的岗位标识`}
|
||||
value={row.id}
|
||||
onChange={(event) => update(row.id, { id: event.target.value })}
|
||||
maxLength={128}
|
||||
className="h-7 max-w-xs font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)] max-md:items-center">
|
||||
<span className="text-xs text-muted-foreground md:hidden">岗位性质</span>
|
||||
<Select value={row.roleType} onValueChange={(value) => changeRoleType(row.id, value as PositionRoleType)}>
|
||||
<SelectTrigger aria-label={`${row.name || "岗位"}的岗位性质`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">普通执行岗</SelectItem>
|
||||
<SelectItem value="intent">意图识别岗(特殊)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{row.roleType === "intent" && (
|
||||
<p className="text-[11px] leading-4 text-[var(--color-main,#0062D9)]">会商入口与收口智能体</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-9 items-center gap-2 max-lg:col-start-2 max-md:col-auto max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)]">
|
||||
<span className="text-xs text-muted-foreground md:hidden">启用状态</span>
|
||||
<Switch checked={row.enabled} onCheckedChange={(checked) => toggleEnabled(row.id, checked)} aria-label={`${row.name || "岗位"}${row.enabled ? "已启用" : "已停用"}`} />
|
||||
<span className={cn("text-xs", row.enabled ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||
{row.enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 max-lg:col-start-3 max-md:col-auto max-md:justify-start">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === 0} onClick={() => move(row.id, "up")} aria-label={`上移 ${row.name}`}>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === rows.length - 1} onClick={() => move(row.id, "down")} aria-label={`下移 ${row.name}`}>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" disabled={row.isBuiltin} onClick={() => remove(row)} aria-label={`删除 ${row.name}`} title={row.isBuiltin ? "内置岗位不能删除,可停用" : "删除岗位"}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<footer className="border-t border-border/70 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-[-2px] text-emerald-600 dark:text-emerald-400" />
|
||||
当前已启用 {rows.filter((row) => row.enabled).length} 个岗位,其中意图识别岗:{rows.find((row) => row.id === enabledIntentId)?.name ?? "未配置"}。
|
||||
</footer>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -23,6 +24,11 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -42,10 +48,12 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
|
||||
import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { usePublicBackground } from "@/core/embed/background";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PublicTask = {
|
||||
@@ -264,6 +272,190 @@ function stripTimestampSuffix(title: string): string {
|
||||
return cleaned.trim() || title;
|
||||
}
|
||||
|
||||
function stripMarkdownToText(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/~~~[\s\S]*?~~~/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^[>\-*+\d.)\s]+/gm, "")
|
||||
.replace(/[`*_~|]/g, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getMarkdownExcerpt(markdown: string): string {
|
||||
const text = stripMarkdownToText(markdown);
|
||||
if (!text) return "";
|
||||
return text.length > 180 ? `${text.slice(0, 180)}...` : text;
|
||||
}
|
||||
|
||||
function countReadableWords(markdown: string): number {
|
||||
const text = stripMarkdownToText(markdown);
|
||||
if (!text) return 0;
|
||||
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0;
|
||||
return cjk + words;
|
||||
}
|
||||
|
||||
function selectorForId(id: string): string {
|
||||
const css = window.CSS;
|
||||
if (css?.escape) return `#${css.escape(id)}`;
|
||||
return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
|
||||
}
|
||||
|
||||
function KnowledgeMarkdownView({
|
||||
content,
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
remarkPlugins?: unknown;
|
||||
rehypePlugins?: unknown;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const headings = useMemo(
|
||||
() => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3),
|
||||
[anchorResolver],
|
||||
);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]);
|
||||
const sectionCount = headings.length;
|
||||
const wordCount = useMemo(() => countReadableWords(content), [content]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHeadingId(headings[0]?.id ?? null);
|
||||
}, [headings]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || headings.length === 0) return;
|
||||
|
||||
const targets = headings
|
||||
.map((item) => root.querySelector<HTMLElement>(selectorForId(item.id)))
|
||||
.filter((item): item is HTMLElement => !!item);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (visible?.target.id) {
|
||||
setActiveHeadingId(visible.target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: root.closest("[data-radix-scroll-area-viewport]") ?? null,
|
||||
rootMargin: "-96px 0px -65% 0px",
|
||||
threshold: [0, 1],
|
||||
},
|
||||
);
|
||||
|
||||
targets.forEach((target) => observer.observe(target));
|
||||
return () => observer.disconnect();
|
||||
}, [headings, content]);
|
||||
|
||||
const scrollToHeading = useCallback((id: string) => {
|
||||
const root = rootRef.current;
|
||||
const target =
|
||||
root?.querySelector<HTMLElement>(selectorForId(id)) ?? document.getElementById(id);
|
||||
setActiveHeadingId(id);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"grid min-h-0 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<article className="min-w-0 overflow-hidden rounded-lg border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="border-b border-slate-100 bg-slate-50/90 px-6 py-5 dark:border-slate-800 dark:bg-slate-900/70 md:px-8">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-950 dark:text-slate-300 dark:ring-slate-800">
|
||||
<BookOpen className="size-3.5" />
|
||||
知识梳理
|
||||
</span>
|
||||
{subtitle && <span className="truncate">{subtitle}</span>}
|
||||
</div>
|
||||
{title && (
|
||||
<h1 className="mt-3 text-xl font-semibold leading-snug text-slate-950 dark:text-slate-50 md:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{excerpt && (
|
||||
<p className="mt-3 max-w-4xl text-sm leading-7 text-slate-600 dark:text-slate-300">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="rounded-md bg-white px-2 py-1 ring-1 ring-slate-200 dark:bg-slate-950 dark:ring-slate-800">
|
||||
{sectionCount || 1} 个章节
|
||||
</span>
|
||||
<span className="rounded-md bg-white px-2 py-1 ring-1 ring-slate-200 dark:bg-slate-950 dark:ring-slate-800">
|
||||
约 {wordCount} 字
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wiki-markdown public-report-markdown mx-auto w-full max-w-[980px] px-6 py-7 md:px-10 md:py-9">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="hidden min-w-0 lg:block">
|
||||
<div className="sticky top-4 rounded-lg border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<List className="size-4" />
|
||||
内容目录
|
||||
</div>
|
||||
{headings.length > 0 ? (
|
||||
<nav className="max-h-[calc(100vh-210px)] space-y-1 overflow-auto pr-1">
|
||||
{headings.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block w-full truncate rounded-md border-l-2 border-transparent px-2 py-1.5 text-left text-xs leading-5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-slate-50",
|
||||
activeHeadingId === item.id &&
|
||||
"border-primary bg-primary/10 font-medium text-primary dark:text-primary",
|
||||
item.level === 2 && "pl-4",
|
||||
item.level === 3 && "pl-7 text-slate-500 dark:text-slate-400",
|
||||
)}
|
||||
title={item.text}
|
||||
onClick={() => scrollToHeading(item.id)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
暂无标题目录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BODY_TAB_VALUE = "__body__";
|
||||
const HTML_TAB_VALUE = "__html__";
|
||||
const RUN_LIST_COLLAPSED_WIDTH = 56;
|
||||
@@ -688,12 +880,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen
|
||||
}
|
||||
if (preview.mode === "markdown") {
|
||||
return (
|
||||
<MarkdownContent
|
||||
<ReportMarkdownView
|
||||
className="max-w-none"
|
||||
content={preview.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
title={preview.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -701,12 +891,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen
|
||||
const content = preview.file.content || "无文件内容";
|
||||
if (isMarkdownFile(preview.file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
<ReportMarkdownView
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
title={preview.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -731,6 +919,7 @@ function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): s
|
||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState<PublicFullscreenPreview | null>(null);
|
||||
const [runMetaOpen, setRunMetaOpen] = useState(false);
|
||||
|
||||
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
||||
const isHtmlRun =
|
||||
@@ -970,7 +1159,29 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<Collapsible open={runMetaOpen} onOpenChange={setRunMetaOpen} className="mt-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-transform", runMetaOpen && "rotate-90")}
|
||||
/>
|
||||
运行信息
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2 grid gap-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<div className="min-w-0 break-all">计划:{formatDate(run.scheduled_for)}</div>
|
||||
<div>开始:{formatDate(run.started_at)}</div>
|
||||
<div>完成:{formatDate(run.finished_at)}</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<p className="hidden">
|
||||
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1122,12 +1333,14 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
无内容。点击「编辑」开始添加。
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
<ReportMarkdownView
|
||||
className="max-w-none pr-2"
|
||||
content={activeSource}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
title={
|
||||
activeTabMeta?.kind === "file"
|
||||
? stripExtension(activeTabMeta.fileName || "")
|
||||
: run.result?.title
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
|
||||
import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
downloadScheduledTaskRunFile,
|
||||
@@ -91,11 +92,12 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
}
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
<ReportMarkdownView
|
||||
className="max-w-none"
|
||||
content={file.content}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
title={file.name.replace(/\.(md|markdown)$/i, "")}
|
||||
badgeLabel="结果文件"
|
||||
compactHeader
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -114,11 +116,11 @@ function FullscreenPreviewContent({ preview }: { preview: FullscreenPreview }) {
|
||||
}
|
||||
if (preview.mode === "markdown") {
|
||||
return (
|
||||
<MarkdownContent
|
||||
<ReportMarkdownView
|
||||
className="max-w-none"
|
||||
content={preview.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
title={preview.title}
|
||||
badgeLabel="正文预览"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -653,11 +655,12 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
<ReportMarkdownView
|
||||
className="max-w-none"
|
||||
content={result.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
title={result.title || "正文预览"}
|
||||
subtitle={`计划时间:${formatDate(run.scheduled_for)}`}
|
||||
badgeLabel="任务报告"
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-sm">
|
||||
|
||||
@@ -793,7 +793,7 @@ export default function ScheduledTasksPage({
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
variant={theme === "iceblue" || theme === "lightyellow" ? "default" : "destructive"}
|
||||
title="删除"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -1034,7 +1034,7 @@ export default function ScheduledTasksPage({
|
||||
{selectedTaskCanEditRuns && run.can_edit !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
variant={theme === "iceblue" || theme === "lightyellow" ? "default" : "destructive"}
|
||||
onClick={() => handleDeleteRun(run.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@@ -21,6 +21,8 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components
|
||||
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
|
||||
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
|
||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
@@ -43,6 +45,8 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||
import PositionRolesPage from "./PositionRolesPage";
|
||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
@@ -66,6 +70,9 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
const storedAuth = getStoredAuth();
|
||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<SidebarProvider>
|
||||
@@ -91,6 +98,22 @@ export default function StrategyRoutes() {
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qa/8bf-roundtable"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EightBfRoundtablePage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qa/position-roundtable"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
|
||||
<Route
|
||||
@@ -303,7 +326,16 @@ export default function StrategyRoutes() {
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
|
||||
<Route
|
||||
path="business-chain-mapping"
|
||||
element={
|
||||
canManageBusinessMappings ? (
|
||||
<BusinessChainMappingPage />
|
||||
) : (
|
||||
<Navigate to="/page/strategy/qa/general" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="business-chains" element={<BusinessChainListPage />} />
|
||||
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
|
||||
|
||||
@@ -407,6 +439,26 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/notification-templates"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<NotificationTemplatesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PositionRolesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/login-whitelist"
|
||||
element={
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
import {
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolveStandardChatFallback,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
@@ -48,12 +53,15 @@ import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PositionRolesPage from './PositionRolesPage';
|
||||
import PluginManagementPage from './PluginManagementPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
||||
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
@@ -71,14 +79,6 @@ function NewAgentPageWithKey() {
|
||||
return <NewAgentPage key={thread_id} />;
|
||||
}
|
||||
|
||||
function isInIframe(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
@@ -117,30 +117,35 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden = false,
|
||||
workspaceSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactActionsHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
defaultHistorySidebarOpen,
|
||||
wrapperOverflowHidden = false,
|
||||
insetOverflowHidden = false,
|
||||
pageSidebarDark = false,
|
||||
forceWorkspaceSidebarCollapsed = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
workspaceSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactActionsHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
/** 全量模式下通用问答等页:聊天记录侧栏默认展开;未指定时紧凑模式仍默认收起。 */
|
||||
defaultHistorySidebarOpen?: boolean;
|
||||
wrapperOverflowHidden?: boolean;
|
||||
insetOverflowHidden?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
/** Deployment-level compact mode: keep only the icon menu rail. */
|
||||
forceWorkspaceSidebarCollapsed?: boolean;
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
|
||||
const showCompactTopActions =
|
||||
headerHidden && shouldShowCompactTopActions(pathname);
|
||||
headerHidden && !compactActionsHidden && shouldShowCompactTopActions(pathname);
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
@@ -169,11 +174,19 @@ export function WorkspaceLayout({
|
||||
key={String(compactWorkspaceSidebar)}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen={
|
||||
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
|
||||
forceWorkspaceSidebarCollapsed
|
||||
? false
|
||||
: (defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar))
|
||||
}
|
||||
open={forceWorkspaceSidebarCollapsed ? false : undefined}
|
||||
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
|
||||
disableToggle={forceWorkspaceSidebarCollapsed}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
<WorkspaceSidebar
|
||||
compactChrome={compactWorkspaceSidebar}
|
||||
forceCollapsed={forceWorkspaceSidebarCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
@@ -224,15 +237,28 @@ function NewChatEntryRoute({
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
if (isInIframe()) {
|
||||
return <Navigate to="/page/workspace/chats_iframe/new" replace />;
|
||||
const location = useLocation();
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
location.pathname,
|
||||
location.search,
|
||||
isEmbeddedHost(),
|
||||
);
|
||||
|
||||
if (iframeEntryTarget) {
|
||||
return <Navigate to={iframeEntryTarget} replace />;
|
||||
}
|
||||
|
||||
// A bare external iframe historically opens the dedicated AgentSelector
|
||||
// page. `resolveChatsIframeEntryRedirect` records that inferred context as
|
||||
// `from=iframe`; explicit full-chat/presentation parameters stay here.
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
@@ -242,25 +268,148 @@ function NewChatEntryRoute({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatsIndexEntryRoute({
|
||||
layoutProps,
|
||||
compactChrome,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
compactChrome: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
location.pathname,
|
||||
location.search,
|
||||
isEmbeddedHost(),
|
||||
);
|
||||
|
||||
if (iframeEntryTarget) {
|
||||
return <Navigate to={iframeEntryTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage compactChrome={compactChrome} />
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatThreadEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
location.pathname,
|
||||
location.search,
|
||||
isEmbeddedHost(),
|
||||
);
|
||||
|
||||
if (iframeEntryTarget) {
|
||||
return <Navigate to={iframeEntryTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
|
||||
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
||||
* remains on the normal full chat workspace.
|
||||
*/
|
||||
function IframeChatEntryRoute({
|
||||
layoutProps,
|
||||
hasThreadParam,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
hasThreadParam: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const standardChatTarget = resolveStandardChatFallback(
|
||||
location.pathname,
|
||||
location.search,
|
||||
);
|
||||
|
||||
if (standardChatTarget) {
|
||||
return <Navigate to={standardChatTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
pageSidebarHidden
|
||||
workspaceSidebarHidden
|
||||
wrapperOverflowHidden={hasThreadParam}
|
||||
insetOverflowHidden={hasThreadParam}
|
||||
>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceRoutes({
|
||||
pageSidebarCollapsed = false,
|
||||
pageSidebarHidden = false,
|
||||
workspaceSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactActionsHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
pageSidebarDark = false,
|
||||
forceWorkspaceSidebarCollapsed = false,
|
||||
}: {
|
||||
pageSidebarCollapsed?: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
workspaceSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactActionsHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
forceWorkspaceSidebarCollapsed?: boolean;
|
||||
}) {
|
||||
const layoutProps = {
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden,
|
||||
workspaceSidebarHidden,
|
||||
headerHidden,
|
||||
compactActionsHidden,
|
||||
compactWorkspaceSidebar,
|
||||
pageSidebarDark,
|
||||
forceWorkspaceSidebarCollapsed,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -270,9 +419,10 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage compactChrome={headerHidden} />
|
||||
</WorkspaceLayout>
|
||||
<ChatsIndexEntryRoute
|
||||
layoutProps={layoutProps}
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -281,43 +431,15 @@ export default function WorkspaceRoutes({
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
pageSidebarHidden
|
||||
workspaceSidebarHidden
|
||||
>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
pageSidebarHidden
|
||||
workspaceSidebarHidden
|
||||
wrapperOverflowHidden
|
||||
insetOverflowHidden
|
||||
>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
@@ -357,7 +479,7 @@ export default function WorkspaceRoutes({
|
||||
<AgentChatPage
|
||||
showAgentList
|
||||
compactAppearanceActions={
|
||||
headerHidden ? <CompactAppearanceActions /> : undefined
|
||||
headerHidden && !compactActionsHidden ? <CompactAppearanceActions /> : undefined
|
||||
}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
@@ -394,7 +516,7 @@ export default function WorkspaceRoutes({
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
compactChrome={headerHidden}
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -428,7 +550,27 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage compactChrome={headerHidden} />
|
||||
<RoundtablePlanningPage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/8bf"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/position"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -490,7 +632,7 @@ export default function WorkspaceRoutes({
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage compactChrome={headerHidden} />
|
||||
<ScheduledTasksPage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -596,6 +738,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PositionRolesPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
|
||||
@@ -651,15 +651,25 @@
|
||||
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
|
||||
}
|
||||
|
||||
/* ── TDesign Select focus / hover ────────────────────────────────────── */
|
||||
/* ── TDesign Select interaction ──────────────────────────────────────── */
|
||||
[data-theme="lightblue"] .light-app-management-page .t-select:hover .t-input,
|
||||
[data-theme="lightblue"] .light-app-management-page .t-select:hover .t-input__wrap {
|
||||
border-color: rgba(0, 98, 217, 0.5);
|
||||
border-color: #A6A6A6;
|
||||
}
|
||||
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused .t-input,
|
||||
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
|
||||
border-color: #0062D9;
|
||||
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
|
||||
/* Opening the options with a mouse is not a validation or selected state. */
|
||||
border-color: #DBDBDB;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Keep a visible focus ring for keyboard navigation only. */
|
||||
@supports selector(.t-select:has(.t-input__inner:focus-visible)) {
|
||||
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused:has(.t-input__inner:focus-visible) .t-input,
|
||||
[data-theme="lightblue"] .light-app-management-page .t-select.t-is-focused:has(.t-input__inner:focus-visible) .t-input__wrap {
|
||||
border-color: #0062D9;
|
||||
box-shadow: 0 0 0 2px rgba(0, 98, 217, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 启用开关 ───────────────────────────────────────────────────────── */
|
||||
@@ -679,6 +689,163 @@
|
||||
background-color: #0056C6;
|
||||
}
|
||||
|
||||
/* Ice blue theme — use #9BCEF1 for surfaces and #3D6E96 for actions so
|
||||
text on primary controls remains readable. */
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-switch-on {
|
||||
background-color: #9BCEF1;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save {
|
||||
color: #18324A;
|
||||
box-shadow: 0 2px 6px rgba(155, 206, 241, 0.42);
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn:hover,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save:hover {
|
||||
background-color: #B7DCF5;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn:active,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save:active {
|
||||
background-color: #84B9DE;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-search-input:focus {
|
||||
border-color: rgba(61, 110, 150, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(155, 206, 241, 0.42);
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-view-toggle-btn.is-active,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-cat-toggle-btn.is-active,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-count strong,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-table tr:hover .lam-row-name,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-param-popover-close,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-param-key,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-card:hover .lam-card-title,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-close:hover,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-add-param-btn:hover {
|
||||
color: #2D587A;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-add-param-btn:hover,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog input[type="text"]:focus {
|
||||
border-color: #5B91B9;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog input[type="text"]:focus,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog textarea:focus-visible,
|
||||
[data-theme="iceblue"] .light-app-management-page .t-select.t-is-focused .t-input,
|
||||
[data-theme="iceblue"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
|
||||
box-shadow: 0 0 0 2px rgba(155, 206, 241, 0.58);
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-icon {
|
||||
background-color: #E1F1FB;
|
||||
color: #2D587A;
|
||||
}
|
||||
|
||||
/* Pale yellow: #F3FBD9 surfaces pair with dark olive interaction text. */
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-switch-on {
|
||||
background-color: #F3FBD9;
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save {
|
||||
color: #303D23;
|
||||
box-shadow: 0 2px 6px rgba(243, 251, 217, 0.72);
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn:hover,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save:hover {
|
||||
background-color: #E4EFBF;
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-primary-btn:active,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-save:active {
|
||||
background-color: #DCEBAD;
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-search-input:focus {
|
||||
border-color: rgba(76, 99, 48, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(243, 251, 217, 0.72);
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-view-toggle-btn.is-active,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-cat-toggle-btn.is-active,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-count strong,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-table tr:hover .lam-row-name,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-param-popover-close,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-param-key,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-card:hover .lam-card-title,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-close:hover,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-add-param-btn:hover {
|
||||
color: #4C6330;
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-add-param-btn:hover,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog input[type="text"]:focus {
|
||||
border-color: #B4C985;
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog input[type="text"]:focus,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog textarea:focus-visible,
|
||||
[data-theme="lightyellow"] .light-app-management-page .t-select.t-is-focused .t-input,
|
||||
[data-theme="lightyellow"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
|
||||
box-shadow: 0 0 0 2px rgba(243, 251, 217, 0.78);
|
||||
}
|
||||
[data-theme="lightyellow"] .light-app-management-page .lam-dialog-icon {
|
||||
background-color: #EEF6D4;
|
||||
color: #4C6330;
|
||||
}
|
||||
|
||||
/* WeKnora green theme. */
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-switch-on {
|
||||
background-color: #07C05F;
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save {
|
||||
box-shadow: 0 2px 6px rgba(7, 192, 95, 0.2);
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn:hover,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save:hover {
|
||||
background-color: #08DD6E;
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn:active,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save:active {
|
||||
background-color: #049B38;
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-search-input:focus {
|
||||
border-color: rgba(7, 192, 95, 0.4);
|
||||
box-shadow: 0 0 0 4px rgba(7, 192, 95, 0.1);
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-view-toggle-btn.is-active,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-cat-toggle-btn.is-active,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-count strong,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-table tr:hover .lam-row-name,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-param-popover-close,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-param-key,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-card:hover .lam-card-title,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-close:hover,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-add-param-btn:hover {
|
||||
color: #049B38;
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-add-param-btn:hover {
|
||||
border-color: #07C05F;
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-icon {
|
||||
background-color: #E9F8EC;
|
||||
color: #049B38;
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog input[type="text"]:focus,
|
||||
[data-theme="lightgreen"] .light-app-management-page .t-select.t-is-focused .t-input,
|
||||
[data-theme="lightgreen"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
|
||||
border-color: #07C05F;
|
||||
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.16);
|
||||
}
|
||||
[data-theme="lightgreen"] .light-app-management-page .t-select:hover .t-input,
|
||||
[data-theme="lightgreen"] .light-app-management-page .t-select:hover .t-input__wrap {
|
||||
border-color: rgba(7, 192, 95, 0.5);
|
||||
}
|
||||
|
||||
/* ── iframe 菜单排序:拖拽行 + 拖拽手柄 ───────────────────────────────── */
|
||||
.light-app-management-page .lam-reorder-row {
|
||||
cursor: grab;
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
background-color: #F9FCFF;
|
||||
}
|
||||
|
||||
[data-theme="lightgreen"] .light-app-management-page,
|
||||
[data-theme="lightgreen"] .light-app-list-page {
|
||||
background-color: #F9FDF9;
|
||||
}
|
||||
|
||||
[data-theme="lightyellow"] .light-app-management-page,
|
||||
[data-theme="lightyellow"] .light-app-list-page {
|
||||
background-color: #FCFDF5;
|
||||
}
|
||||
|
||||
.dark .light-app-management-page,
|
||||
.dark .light-app-list-page {
|
||||
background-color: var(--color-bg-page);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# 岗位多智能体会商页面开发说明
|
||||
|
||||
## 1. 页面定位
|
||||
|
||||
岗位多智能体会商将一个业务链条拆成多个“岗位视角”的单智能体问答:每个席位仍使用既有的 LangGraph 对话线程完成流式生成,岗位会商服务只负责保存任务快照、冻结后的链条、节点状态与线程索引。
|
||||
|
||||
因此它不是新的多智能体调度图,也不创建后台会商任务。页面的核心约束是:
|
||||
|
||||
- 一条岗位会商会话对应一个任务和一份冻结的业务链配置;
|
||||
- 一个业务链席位对应一个 `PositionRoundtableNode` 和一条直接对话线程;
|
||||
- 节点写出 Markdown 交付物后才能被视为完成并解锁后序;
|
||||
- 所有岗位交付完成后,测试分析岗可以依次进入“方案总结”和“行动规划”两个收口智能体;
|
||||
- 页面中的文件预览沙箱只有一个实例,不会为多个节点重复打开或同时流式渲染。
|
||||
|
||||
前端入口位于 `src/pages/StrategyRoutes.tsx`:`qa/position-roundtable`。
|
||||
|
||||
## 2. URL 与会话恢复
|
||||
|
||||
推荐访问地址:
|
||||
|
||||
```text
|
||||
/#/page/strategy/qa/position-roundtable?taskId=<任务ID>&sessionId=<会商会话ID>&positionId=<岗位ID>
|
||||
```
|
||||
|
||||
| 参数 | 是否必填 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 |
|
||||
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
|
||||
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
|
||||
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
|
||||
|
||||
当前岗位 ID 定义在 `lib/positions.ts`:
|
||||
|
||||
| ID | 页面岗位 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| `intelligence` | 测试分析岗 | 完成任务意图识别、业务链确认,以及收口智能体入口。 |
|
||||
| `planning` | 组织测试岗 | 承接上游信息并完成组织侧分析。 |
|
||||
| `solution` | 方案设计岗 | 产出方案侧 Markdown 交付物。 |
|
||||
| `evaluation` | 综合测试岗 | 汇总评估并给出综合建议。 |
|
||||
|
||||
切换岗位时,`PositionRoundtablePage` 会关闭当前沙箱、清空当前节点/收口智能体选择,并写回 `positionId`。不要在子组件中直接修改 URL,否则容易留下上一岗位的预览状态。
|
||||
|
||||
## 3. 页面结构与职责
|
||||
|
||||
```text
|
||||
PositionRoundtablePage
|
||||
├─ 左侧:任务分析
|
||||
│ ├─ 任务卡片(名称、简介、来源、下发时间)
|
||||
│ ├─ 意图分析
|
||||
│ └─ 内嵌业务链流程图
|
||||
├─ 中间:当前岗位的对话区
|
||||
│ ├─ 测试分析岗:Step1Panel + PositionIntentInput
|
||||
│ ├─ 普通席位:PositionNodeChat + PositionAgentInput
|
||||
│ └─ 收口阶段:PositionBuiltinAgentChat + PositionAgentInput
|
||||
├─ 右侧:智能体配置 / 交付产物抽屉
|
||||
└─ 文件预览:唯一的 ArtifactFileDetail 沙箱实例
|
||||
```
|
||||
|
||||
主要文件:
|
||||
|
||||
| 文件 | 责任 |
|
||||
| --- | --- |
|
||||
| `pages/PositionRoundtablePage.tsx` | 页面状态机、URL 同步、任务与会话恢复、岗位/节点切换、唯一沙箱预览。 |
|
||||
| `api/sessions.ts` | 岗位会商 API 的前端类型、snake_case/camelCase 映射和请求函数。 |
|
||||
| `components/PositionNodeChat.tsx` | 一个普通业务链节点的一对一对话、线程绑定、上游上下文注入与交付完成回写。 |
|
||||
| `components/PositionBuiltinAgentChat.tsx` | 方案总结、行动规划的直接对话和“准备 → 线程就绪 → 发送 → 归档”流程。 |
|
||||
| `components/PositionIntentInput.tsx` | 仅测试分析岗使用的意图输入框。样式、工具栏和按钮均与其他页面隔离。 |
|
||||
| `components/PositionAgentInput.tsx` | 普通节点与收口智能体共用的岗位专用输入框边界。 |
|
||||
| `components/PositionAgentSwitcher.tsx` | 测试分析岗中两个收口智能体的全名切换按钮。 |
|
||||
| `lib/positions.ts` | 前端岗位目录和稳定岗位 ID。 |
|
||||
|
||||
## 4. 状态流转
|
||||
|
||||
```text
|
||||
意图识别
|
||||
└─ 确认意图并启动业务链
|
||||
├─ 冻结 taskSnapshot / intentSnapshot / chainSnapshot
|
||||
├─ 按业务链席位创建节点
|
||||
└─ intelligence 岗节点自动标记完成
|
||||
|
||||
普通业务链节点
|
||||
└─ 直接问答并生成 Markdown
|
||||
├─ 写入 LangGraph 线程
|
||||
├─ 回写 node.threadId
|
||||
├─ 保存 latestAnswer 与 artifactManifest
|
||||
└─ Markdown 交付成功后解锁后续阶段
|
||||
|
||||
全部普通节点已交付
|
||||
└─ 方案总结(测试分析岗)
|
||||
└─ 行动规划(测试分析岗)
|
||||
```
|
||||
|
||||
### 4.1 意图识别与业务链启动
|
||||
|
||||
测试分析岗复用 `Step1Panel` 的消息和状态逻辑;页面通过 `inputArea` 插槽替换为 `PositionIntentInput`,因此不会改动普通会商 Step 1 的输入样式。
|
||||
|
||||
`handleActivateChain` 启动前必须同时满足:
|
||||
|
||||
1. 选择了业务链;
|
||||
2. 业务链至少有一个席位;
|
||||
3. 每个席位已配置智能体和岗位;
|
||||
4. `intent.intentReady` 存在结构化意图结果。
|
||||
|
||||
启动成功后会调用:
|
||||
|
||||
1. `createPositionSession`(首次启动时);
|
||||
2. `savePositionIntent`;
|
||||
3. `activatePositionSession`。
|
||||
|
||||
后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。
|
||||
|
||||
### 4.2 普通节点问答
|
||||
|
||||
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
|
||||
|
||||
首次创建线程时,`onStart` 会调用 `bindPositionNodeThread`。组件还监听 SDK 实际线程 ID,处理“线程创建后、绑定写入前刷新页面”的竞态。
|
||||
|
||||
流结束后,组件会读取该线程的产物列表并调用 `completePositionNodeTurn`。普通节点只有交付 Markdown 文件才会变为 `done`;单纯继续追问只会保存回答,不会误解锁下游节点。
|
||||
|
||||
### 4.3 收口智能体
|
||||
|
||||
收口阶段只有测试分析岗可见:
|
||||
|
||||
- 方案总结:全部岗位交付完成后可开始;
|
||||
- 行动规划:方案总结完成后可开始。
|
||||
|
||||
`PositionBuiltinAgentChat` 不使用后台任务。每次发送先调用 `preparePositionSummary` 或 `preparePositionActionPlan`,从服务端得到冻结的输入提示词、来源版本和可复用线程 ID;待 `useThreadStream` 真正绑定到该线程后才发送首条消息。这个两步提交避免了界面先出现用户气泡、实际却没有启动流的竞态。
|
||||
|
||||
完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。方案总结(以及行动规划的 Markdown 部分)若模型只返回了对话正文、遗漏 `write_file`,服务端会将该正文自动写入对应线程的 `outputs/` 目录并登记为 Markdown 产物,因此用户始终同时获得聊天正文与可预览、下载的报告文件;行动规划仍必须另外生成 `action-plan-subtasks.json`。
|
||||
|
||||
## 5. 文件预览沙箱
|
||||
|
||||
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
||||
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
|
||||
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
||||
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
||||
|
||||
不要在节点组件中直接操作全局 `ArtifactsContext` 或创建第二个 `ArtifactFileDetail`,否则会出现预览重复、关闭失效和更新深度异常。
|
||||
|
||||
## 6. 输入框隔离约定
|
||||
|
||||
岗位会商有两类输入框,均应只在 `src/position-roundtable/components/` 内调整:
|
||||
|
||||
| 组件 | 使用范围 | 调整原则 |
|
||||
| --- | --- | --- |
|
||||
| `PositionIntentInput` | 测试分析岗意图识别 | 完整自有布局;发送和阶段按钮均在同一输入框内。 |
|
||||
| `PositionAgentInput` | 普通岗位节点、方案总结、行动规划 | 目前包装全局 `InputBox`,保留全部对话能力,同时提供 `position-roundtable-agent-input` 样式边界。 |
|
||||
|
||||
禁止直接修改 `components/workspace/input-box` 或默认 `Step1Panel` 的输入样式来满足岗位会商需求。这会影响普通工作区、其他业务和原始多智能体会商。
|
||||
|
||||
## 7. API 契约
|
||||
|
||||
前端基地址:`/api/position-roundtable`。LangGraph 负责实时执行;SQL 同时保存岗位会商索引、结构化状态、完整对话恢复快照和产物正文副本,避免应用或 checkpoint 卷重启后页面变空。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
|
||||
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
|
||||
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
|
||||
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
|
||||
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
|
||||
| `PUT` | `/sessions/{id}/intent` | 保存意图线程 ID、结构化意图和完整意图对话快照。 |
|
||||
| `POST` | `/sessions/{id}/activate` | 冻结业务链并创建节点。 |
|
||||
| `POST` | `/sessions/{id}/archive` | 归档会话,后续仅允许查看。 |
|
||||
| `POST` | `/sessions/{id}/nodes/{nodeKey}/thread` | 绑定普通节点的 LangGraph 线程。 |
|
||||
| `POST` | `/sessions/{id}/nodes/{nodeKey}/context` | 取得当前轮隐藏交接上下文。 |
|
||||
| `POST` | `/sessions/{id}/nodes/{nodeKey}/complete-turn` | 保存回答、产物清单、上游版本并推进状态。 |
|
||||
| `PUT` | `/sessions/{id}/nodes/{nodeKey}/conversation` | 保存普通岗位节点的消息与未发送草稿。 |
|
||||
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
|
||||
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
|
||||
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
|
||||
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
|
||||
|
||||
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
|
||||
|
||||
## 8. 演示数据与免登录独立模式
|
||||
|
||||
完整演示数据只有一份事实源:
|
||||
|
||||
```text
|
||||
src/position-roundtable/demo/position-roundtable-full.json
|
||||
```
|
||||
|
||||
它包含任务、意图识别、业务链、全部岗位节点对话、岗位产物正文、方案总结和行动规划。修改数据结构时需要同步更新 `demoKey`,避免已初始化的旧 SQL 演示记录与新结构混用。
|
||||
|
||||
两个运行时开关用途不同:
|
||||
|
||||
| 配置 | 默认值 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `VITE_POSITION_ROUNDTABLE_DEMO_MODE` | `false` | 正常登录和正常后台请求;进入演示任务时先调用 `/demo/seed` 幂等写入数据库,失败则读取前端 JSON 只读兜底。 |
|
||||
| `VITE_POSITION_ROUNDTABLE_STANDALONE_MODE` | `false` | 纯前端展台模式;跳过登录并直达岗位分析页,不加载全局后台配置,不请求模型、业务链、智能体、会话或 LangGraph 接口,只读渲染同一份 JSON。 |
|
||||
|
||||
部署后可直接修改 `dist/runtime-config.js`(容器部署通常挂载外部 `runtime-config.js`),无需重新构建。独立模式会绕过鉴权,只允许在隔离的演示环境开启,生产环境必须保持 `false`。
|
||||
|
||||
自动意图线程的时序约束:
|
||||
|
||||
1. 外部页面只传 `taskId` 时,先完成历史发现或创建 SQL 会话行;
|
||||
2. 是否初始化智能体只看真实 `intentThreadId`,不能用 URL 是否已有 `sessionId` 代替;
|
||||
3. `initIntent` 返回真实线程前,不保存“无线程欢迎语快照”;
|
||||
4. 绑定线程后立即保存线程 ID,再合并数据库中的恢复消息和草稿。
|
||||
|
||||
这四步用于避免新建会话的恢复 effect 抢先覆盖并取消正在进行的线程初始化。
|
||||
|
||||
## 9. 开发与排查清单
|
||||
|
||||
1. 新增岗位时:通过「设置和更多 → 岗位角色配置」(管理员)保存到 `/api/position-roles`;`POSITION_OPTIONS` 仅作为接口暂不可用时的前端回退。业务链席位的 `position_id` 必须使用稳定 ID,不能使用显示名称。
|
||||
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
|
||||
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
|
||||
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
|
||||
5. 变更输入框视觉时:只改对应的岗位专用输入组件,不要向全局 `InputBox` 添加岗位样式。
|
||||
6. 验证最少覆盖:新建会话、刷新恢复、切岗位、单节点多智能体切换、生成 Markdown、关闭后再次对话、历史会话、方案总结、行动规划。
|
||||
|
||||
## 10. 构建验证
|
||||
|
||||
```powershell
|
||||
cd frontend-web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
提交前还应执行:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 11. 岗位角色配置
|
||||
|
||||
岗位角色配置只管理岗位会商页面的工作视角,不等同于组织岗位或权限。后端启动会幂等补齐
|
||||
`intelligence`、`planning`、`solution`、`evaluation` 四个内置岗位;已保存的名称、说明、顺序、
|
||||
启停和性质不会被种子覆盖。一个启用的“意图识别岗(特殊)”是必需的,负责第一阶段意图理解并
|
||||
显示方案总结、行动规划两个收口智能体。内置岗位为兼容历史业务链而不可删除,但可以修改或停用;
|
||||
自定义岗位可以新增、排序和删除。
|
||||
@@ -0,0 +1,678 @@
|
||||
/** Client for the independent position-collaboration Session / Node API.
|
||||
*
|
||||
* Messages themselves stay in LangGraph threads. These endpoints only persist
|
||||
* task/intent snapshots, frozen chain topology, node states and indexes that
|
||||
* let the UI recover the correct thread after a refresh.
|
||||
*/
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/**
|
||||
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
|
||||
* 不应在组件内自行把节点改成 done 或 ready。
|
||||
*/
|
||||
export type PositionNodeStatus =
|
||||
| "locked"
|
||||
| "ready"
|
||||
| "running"
|
||||
| "done"
|
||||
| "stale"
|
||||
| "rejected"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* SQL-owned recovery copy for a chat surface. LangGraph remains the execution
|
||||
* source, but this snapshot keeps history and an unsent composer draft
|
||||
* recoverable when a deployment replaces the checkpoint or sandbox volume.
|
||||
*/
|
||||
export interface PositionConversationSnapshot {
|
||||
messages: Array<Record<string, unknown>>;
|
||||
timelineEntries?: Array<Record<string, unknown>>;
|
||||
inputDraft?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结业务链中的一个席位。
|
||||
*
|
||||
* `threadId` 指向真实的 LangGraph 对话;节点表只承担“席位 ↔ 线程”索引、
|
||||
* 上游版本与交付清单的职责,绝不保存完整聊天消息。
|
||||
*/
|
||||
export interface PositionRoundtableNode {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
nodeKey: string;
|
||||
stageIndex: number;
|
||||
seatIndex: number;
|
||||
agentId: string;
|
||||
positionId: string | null;
|
||||
threadId: string | null;
|
||||
status: PositionNodeStatus;
|
||||
latestAnswer: string | null;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
conversationSnapshot: PositionConversationSnapshot | null;
|
||||
revision: number;
|
||||
upstreamRevision: Record<string, unknown> | null;
|
||||
rejectionCount: number;
|
||||
lastRejection: Record<string, unknown> | null;
|
||||
invalidatedBy: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位会商会话的可恢复快照。
|
||||
*
|
||||
* 任务、意图与链条在启动后被分别冻结,避免外部任务或链条配置发生变化时
|
||||
* 改写已经开始的协同过程。`nodes` 只在详情接口中携带。
|
||||
*/
|
||||
export interface PositionRoundtableSession {
|
||||
id: string;
|
||||
externalTaskId: string | null;
|
||||
taskSnapshot: Record<string, unknown>;
|
||||
chainId: string | null;
|
||||
chainSnapshot: Record<string, unknown> | null;
|
||||
intentThreadId: string | null;
|
||||
intentSnapshot: Record<string, unknown> | null;
|
||||
intentConversation: PositionConversationSnapshot | null;
|
||||
summaryThreadId: string | null;
|
||||
summarySnapshot: Record<string, unknown> | null;
|
||||
summaryConversation: PositionConversationSnapshot | null;
|
||||
actionPlanThreadId: string | null;
|
||||
actionPlanSnapshot: Record<string, unknown> | null;
|
||||
actionPlanConversation: PositionConversationSnapshot | null;
|
||||
status: "intent_pending" | "active" | "completed" | "archived";
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
nodes?: PositionRoundtableNode[];
|
||||
}
|
||||
|
||||
interface ServerNode {
|
||||
id: string;
|
||||
session_id: string;
|
||||
node_key: string;
|
||||
stage_index: number;
|
||||
seat_index: number;
|
||||
agent_id: string;
|
||||
position_id: string | null;
|
||||
thread_id: string | null;
|
||||
status: PositionNodeStatus;
|
||||
latest_answer: string | null;
|
||||
artifact_manifest?: Array<Record<string, unknown>>;
|
||||
conversation_snapshot?: PositionConversationSnapshot | null;
|
||||
revision?: number;
|
||||
upstream_revision?: Record<string, unknown> | null;
|
||||
rejection_count?: number;
|
||||
last_rejection?: Record<string, unknown> | null;
|
||||
invalidated_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface ServerSession {
|
||||
id: string;
|
||||
external_task_id: string | null;
|
||||
task_snapshot?: Record<string, unknown>;
|
||||
chain_id: string | null;
|
||||
chain_snapshot?: Record<string, unknown> | null;
|
||||
intent_thread_id: string | null;
|
||||
intent_snapshot?: Record<string, unknown> | null;
|
||||
intent_conversation?: PositionConversationSnapshot | null;
|
||||
summary_thread_id?: string | null;
|
||||
summary_snapshot?: Record<string, unknown> | null;
|
||||
summary_conversation?: PositionConversationSnapshot | null;
|
||||
action_plan_thread_id?: string | null;
|
||||
action_plan_snapshot?: Record<string, unknown> | null;
|
||||
action_plan_conversation?: PositionConversationSnapshot | null;
|
||||
status: PositionRoundtableSession["status"];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
nodes?: ServerNode[];
|
||||
}
|
||||
|
||||
const PRIMARY_API_PATH = "/api/position-roundtable";
|
||||
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
|
||||
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
|
||||
|
||||
/**
|
||||
* Some intranet gateways only forward the long-established `/api/multi-agent`
|
||||
* namespace. The position workspace was added later under its own top-level
|
||||
* prefix, so a frontend-only release can work locally but receive FastAPI's
|
||||
* route-level `{"detail":"Not Found"}` after deployment.
|
||||
*
|
||||
* Retry only that exact "route is absent" response against the compatibility
|
||||
* namespace. A domain 404 such as "Position roundtable session not found"
|
||||
* remains a real 404 and is never hidden by this routing fallback.
|
||||
*/
|
||||
let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null;
|
||||
|
||||
function replaceApiPath(url: string, apiPath: string): string {
|
||||
return url.replace(PRIMARY_API_PATH, apiPath);
|
||||
}
|
||||
|
||||
async function isMissingRoute(response: Response): Promise<boolean> {
|
||||
if (response.status !== 404) return false;
|
||||
const raw = await response.clone().text().catch(() => "");
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { detail?: unknown };
|
||||
return parsed?.detail === "Not Found";
|
||||
} catch {
|
||||
const normalized = raw.trim();
|
||||
return (
|
||||
normalized === "Not Found" ||
|
||||
/<title>\s*404 Not Found\s*<\/title>/i.test(normalized)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function positionApiFetch(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
if (resolvedApiPath === COMPAT_API_PATH) {
|
||||
const compatibilityResponse = await apiFetch(
|
||||
replaceApiPath(input, COMPAT_API_PATH),
|
||||
init,
|
||||
);
|
||||
if (!(await isMissingRoute(compatibilityResponse))) {
|
||||
return compatibilityResponse;
|
||||
}
|
||||
// A gateway may be reloaded while the SPA remains open. Re-probe the
|
||||
// canonical route instead of pinning the tab to a now-stale route choice.
|
||||
resolvedApiPath = null;
|
||||
}
|
||||
|
||||
const primaryResponse = await apiFetch(input, init);
|
||||
if (!(await isMissingRoute(primaryResponse))) {
|
||||
resolvedApiPath = PRIMARY_API_PATH;
|
||||
return primaryResponse;
|
||||
}
|
||||
|
||||
const compatibilityResponse = await apiFetch(
|
||||
replaceApiPath(input, COMPAT_API_PATH),
|
||||
init,
|
||||
);
|
||||
if (!(await isMissingRoute(compatibilityResponse))) {
|
||||
resolvedApiPath = COMPAT_API_PATH;
|
||||
return compatibilityResponse;
|
||||
}
|
||||
|
||||
// Preserve the canonical response/error when neither route exists. This
|
||||
// keeps diagnostics stable and identifies an outdated backend release.
|
||||
return primaryResponse;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, label: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
if (await isMissingRoute(response)) {
|
||||
throw new Error(
|
||||
`${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` +
|
||||
`已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`,
|
||||
);
|
||||
}
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(`${label} failed (${response.status}): ${detail}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing Session is a recoverable navigation state, not a generic gateway
|
||||
* failure. It commonly occurs when another tab (or the history menu) deletes
|
||||
* the Session that is still present in the current URL.
|
||||
*/
|
||||
export class PositionSessionNotFoundError extends Error {
|
||||
readonly sessionId: string;
|
||||
|
||||
constructor(sessionId: string) {
|
||||
super(`Position roundtable session not found: ${sessionId}`);
|
||||
this.name = "PositionSessionNotFoundError";
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPositionSessionNotFoundError(
|
||||
error: unknown,
|
||||
): error is PositionSessionNotFoundError {
|
||||
return error instanceof PositionSessionNotFoundError;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 使用 snake_case,页面使用 camelCase。所有字段映射集中在这里,
|
||||
* 组件禁止直接依赖后端字段名,避免 API 演进时遗漏状态字段。
|
||||
*/
|
||||
function toNode(node: ServerNode): PositionRoundtableNode {
|
||||
return {
|
||||
id: node.id,
|
||||
sessionId: node.session_id,
|
||||
nodeKey: node.node_key,
|
||||
stageIndex: node.stage_index,
|
||||
seatIndex: node.seat_index,
|
||||
agentId: node.agent_id,
|
||||
positionId: node.position_id,
|
||||
threadId: node.thread_id,
|
||||
status: node.status,
|
||||
latestAnswer: node.latest_answer,
|
||||
artifactManifest: node.artifact_manifest ?? [],
|
||||
conversationSnapshot: node.conversation_snapshot ?? null,
|
||||
revision: node.revision ?? 0,
|
||||
upstreamRevision: node.upstream_revision ?? null,
|
||||
rejectionCount: node.rejection_count ?? 0,
|
||||
lastRejection: node.last_rejection ?? null,
|
||||
invalidatedBy: node.invalidated_by ?? null,
|
||||
createdAt: node.created_at,
|
||||
updatedAt: node.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 同上:会话快照在这一层完成默认值填充与字段转换。 */
|
||||
function toSession(session: ServerSession): PositionRoundtableSession {
|
||||
const result: PositionRoundtableSession = {
|
||||
id: session.id,
|
||||
externalTaskId: session.external_task_id,
|
||||
taskSnapshot: session.task_snapshot ?? {},
|
||||
chainId: session.chain_id,
|
||||
chainSnapshot: session.chain_snapshot ?? null,
|
||||
intentThreadId: session.intent_thread_id,
|
||||
intentSnapshot: session.intent_snapshot ?? null,
|
||||
intentConversation: session.intent_conversation ?? null,
|
||||
summaryThreadId: session.summary_thread_id ?? null,
|
||||
summarySnapshot: session.summary_snapshot ?? null,
|
||||
summaryConversation: session.summary_conversation ?? null,
|
||||
actionPlanThreadId: session.action_plan_thread_id ?? null,
|
||||
actionPlanSnapshot: session.action_plan_snapshot ?? null,
|
||||
actionPlanConversation: session.action_plan_conversation ?? null,
|
||||
status: session.status,
|
||||
createdAt: session.created_at,
|
||||
updatedAt: session.updated_at,
|
||||
};
|
||||
if (Array.isArray(session.nodes)) {
|
||||
result.nodes = session.nodes.map(toNode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
|
||||
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
|
||||
const result = await readJson<ServerSession[]>(
|
||||
await positionApiFetch(`${BASE()}/sessions${params}`),
|
||||
"list position sessions",
|
||||
);
|
||||
return result.map(toSession);
|
||||
}
|
||||
|
||||
export async function createPositionSession(input: {
|
||||
externalTaskId?: string;
|
||||
taskSnapshot: Record<string, unknown>;
|
||||
}): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
external_task_id: input.externalTaskId || null,
|
||||
task_snapshot: input.taskSnapshot,
|
||||
}),
|
||||
}),
|
||||
"create position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
|
||||
if (response.status === 404 && !(await isMissingRoute(response))) {
|
||||
// Consume the body so the connection can be reused, then expose a typed
|
||||
// error that the page can resolve by selecting another history record.
|
||||
await response.text().catch(() => "");
|
||||
throw new PositionSessionNotFoundError(sessionId);
|
||||
}
|
||||
const result = await readJson<ServerSession>(response, "get position session");
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function updatePositionTaskSnapshot(
|
||||
sessionId: string,
|
||||
taskSnapshot: Record<string, unknown>,
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ task_snapshot: taskSnapshot }),
|
||||
}),
|
||||
"update position task",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function savePositionIntent(
|
||||
sessionId: string,
|
||||
input: {
|
||||
intentThreadId?: string | null;
|
||||
intentSnapshot: object | null;
|
||||
conversationSnapshot?: PositionConversationSnapshot | null;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent_thread_id: input.intentThreadId || null,
|
||||
intent_snapshot: input.intentSnapshot,
|
||||
conversation_snapshot: input.conversationSnapshot ?? null,
|
||||
}),
|
||||
}),
|
||||
"save position intent",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function activatePositionSession(
|
||||
sessionId: string,
|
||||
chainId: string,
|
||||
): Promise<PositionRoundtableSession> {
|
||||
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chain_id: chainId }),
|
||||
}),
|
||||
"activate position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"archive position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function deletePositionSession(sessionId: string): Promise<void> {
|
||||
const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// DELETE is deliberately idempotent from the UI's perspective. A stale
|
||||
// history row that is already gone still needs to be removed locally.
|
||||
if (response.status === 404 && !(await isMissingRoute(response))) {
|
||||
await response.text().catch(() => "");
|
||||
return;
|
||||
}
|
||||
await readJson<{ deleted: boolean }>(response, "delete position session");
|
||||
}
|
||||
|
||||
export async function bindPositionNodeThread(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
threadId: string,
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
await positionApiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread_id: threadId }),
|
||||
},
|
||||
),
|
||||
"bind position node thread",
|
||||
);
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
export async function savePositionNodeConversation(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: {
|
||||
threadId?: string | null;
|
||||
conversationSnapshot: PositionConversationSnapshot;
|
||||
},
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
await positionApiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId || null,
|
||||
conversation_snapshot: input.conversationSnapshot,
|
||||
}),
|
||||
},
|
||||
),
|
||||
"save position node conversation",
|
||||
);
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
export async function savePositionBuiltinConversation(
|
||||
sessionId: string,
|
||||
kind: "summary" | "action_plan",
|
||||
input: {
|
||||
threadId?: string | null;
|
||||
conversationSnapshot: PositionConversationSnapshot;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const path = kind === "summary" ? "summary/state" : "action-plan/state";
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId || null,
|
||||
conversation_snapshot: input.conversationSnapshot,
|
||||
}),
|
||||
}),
|
||||
`save position ${kind} conversation`,
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export interface PositionDemoSeedPayload {
|
||||
demo_key: string;
|
||||
session: Record<string, unknown>;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the canonical frontend demo fixture into the normal SQL tables.
|
||||
*
|
||||
* The backend makes this operation idempotent per account and demo key. It
|
||||
* returns the same Session shape as a real workflow, so no demo-only rendering
|
||||
* branch is needed after a successful import.
|
||||
*/
|
||||
export async function seedPositionDemoSession(
|
||||
payload: PositionDemoSeedPayload,
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/demo/seed`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
"seed position demo session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在普通节点发送前取得受长度限制的隐藏交接上下文。
|
||||
*
|
||||
* 该上下文会作为 `task_external_context` 发送给 LangGraph,而不是渲染在聊天
|
||||
* 消息列表中;这样既能让节点承接上游,又不会把整段交接材料重复展示给用户。
|
||||
*/
|
||||
export async function getPositionNodeContext(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return readJson<Record<string, unknown>>(
|
||||
await positionApiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"build position node context",
|
||||
);
|
||||
}
|
||||
|
||||
export async function completePositionNodeTurn(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: {
|
||||
latestAnswer: string;
|
||||
artifactManifest?: Array<Record<string, unknown>>;
|
||||
upstreamRevision?: Record<string, unknown> | null;
|
||||
},
|
||||
): Promise<PositionRoundtableNode> {
|
||||
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
||||
const result = await readJson<ServerNode>(
|
||||
await positionApiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
latest_answer: input.latestAnswer,
|
||||
artifact_manifest: input.artifactManifest ?? [],
|
||||
upstream_revision: input.upstreamRevision ?? null,
|
||||
}),
|
||||
},
|
||||
),
|
||||
"complete position node turn",
|
||||
);
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
export async function rejectPositionNode(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: { reason?: string; title?: string },
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
// ``title`` is retained for older callers. The current position
|
||||
// workspace deliberately sends only an optional reason.
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
reason: input.reason ?? "",
|
||||
}),
|
||||
},
|
||||
),
|
||||
"reject position node",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export interface PositionBuiltinRunPreparation {
|
||||
agentId: string;
|
||||
displayName: string;
|
||||
threadId: string | null;
|
||||
prompt: string;
|
||||
sourceRevisions: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ServerBuiltinRunPreparation {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
thread_id?: string | null;
|
||||
prompt: string;
|
||||
source_revisions?: Record<string, number>;
|
||||
}
|
||||
|
||||
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
|
||||
return {
|
||||
agentId: value.agent_id,
|
||||
displayName: value.display_name,
|
||||
threadId: value.thread_id ?? null,
|
||||
prompt: value.prompt,
|
||||
sourceRevisions: value.source_revisions ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
type BuiltinKind = "summary" | "action_plan";
|
||||
|
||||
async function prepareBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
|
||||
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
||||
const result = await readJson<ServerBuiltinRunPreparation>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
}),
|
||||
`prepare position ${kind}`,
|
||||
);
|
||||
return toBuiltinRunPreparation(result);
|
||||
}
|
||||
|
||||
async function completeBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
input: {
|
||||
threadId: string;
|
||||
content: string;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
sourceRevisions: Record<string, number>;
|
||||
model?: string;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
|
||||
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId,
|
||||
content: input.content,
|
||||
artifact_manifest: input.artifactManifest,
|
||||
source_revisions: input.sourceRevisions,
|
||||
model: input.model || null,
|
||||
}),
|
||||
}),
|
||||
`complete position ${kind}`,
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export function preparePositionSummary(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "summary", message);
|
||||
}
|
||||
|
||||
export function completePositionSummary(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "summary", input);
|
||||
}
|
||||
|
||||
export function preparePositionActionPlan(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "action_plan", message);
|
||||
}
|
||||
|
||||
export function completePositionActionPlan(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "action_plan", input);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
|
||||
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
|
||||
draftKey?: string;
|
||||
draftValue?: string;
|
||||
onDraftChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 岗位会商的岗位智能体输入框。
|
||||
*
|
||||
* 目前保留 InputBox 的完整交互能力;后续本页面需要独立调整视觉或工具栏时,
|
||||
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
|
||||
*/
|
||||
export function PositionAgentInput({
|
||||
className,
|
||||
draftKey = "",
|
||||
draftValue = "",
|
||||
onDraftChange,
|
||||
...props
|
||||
}: PositionAgentInputProps) {
|
||||
const { textInput } = usePromptInputController();
|
||||
const activeDraftKeyRef = useRef("");
|
||||
const restoringValueRef = useRef<string | null>(null);
|
||||
const textInputRef = useRef(textInput);
|
||||
textInputRef.current = textInput;
|
||||
|
||||
useEffect(() => {
|
||||
activeDraftKeyRef.current = draftKey;
|
||||
if (textInputRef.current.value !== draftValue) {
|
||||
restoringValueRef.current = draftValue;
|
||||
textInputRef.current.setInput(draftValue);
|
||||
} else {
|
||||
restoringValueRef.current = null;
|
||||
}
|
||||
}, [draftKey, draftValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDraftKeyRef.current !== draftKey) return;
|
||||
if (restoringValueRef.current !== null) {
|
||||
if (textInput.value === restoringValueRef.current) {
|
||||
restoringValueRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
onDraftChange?.(textInput.value);
|
||||
}, [draftKey, onDraftChange, textInput.value]);
|
||||
|
||||
// Keep the page-specific class on the wrapper rather than adding a variant
|
||||
// to InputBox. Node chat and built-in chat can evolve together while every
|
||||
// other workspace keeps the global composer untouched.
|
||||
return (
|
||||
<InputBox
|
||||
{...props}
|
||||
className={cn("position-roundtable-agent-input w-full", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PositionBuiltinAgentKind = "summary" | "action_plan";
|
||||
|
||||
export interface PositionBuiltinAgentSwitchOption {
|
||||
kind: PositionBuiltinAgentKind;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
interface PositionBuiltinAgentTabsProps {
|
||||
activeKind: PositionBuiltinAgentKind | null;
|
||||
options: PositionBuiltinAgentSwitchOption[];
|
||||
isStreaming?: boolean;
|
||||
onSelect: (kind: PositionBuiltinAgentKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two closing agents are intentionally exposed as full-name buttons
|
||||
* instead of another dropdown: users can see the whole progression and jump
|
||||
* between the two stages with one click.
|
||||
*/
|
||||
export function PositionBuiltinAgentTabs({
|
||||
activeKind,
|
||||
options,
|
||||
isStreaming,
|
||||
onSelect,
|
||||
}: PositionBuiltinAgentTabsProps) {
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ml-auto mr-12 flex shrink-0 items-center gap-1.5"
|
||||
aria-label="收口智能体切换"
|
||||
title={isStreaming ? "当前智能体正在生成,完成后可切换" : "切换收口智能体"}
|
||||
>
|
||||
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
|
||||
收口智能体
|
||||
</span>
|
||||
{options.map((option) => {
|
||||
const active = activeKind === option.kind;
|
||||
const disabled = Boolean(option.disabled || (isStreaming && !active));
|
||||
return (
|
||||
<button
|
||||
key={option.kind}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
title={disabled ? option.disabledReason || "当前阶段暂不可进入" : option.label}
|
||||
onClick={() => onSelect(option.kind)}
|
||||
className={cn(
|
||||
"inline-flex h-8 min-w-[126px] items-center justify-center rounded-lg border px-2.5 text-xs font-semibold transition",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-slate-200 bg-white text-slate-700 hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]",
|
||||
disabled &&
|
||||
"cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 opacity-75 hover:border-slate-200 hover:text-slate-400 dark:border-[#2b8ffe]/20 dark:bg-[#082d5f]/55 dark:text-[#7595be]",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button as TButton } from "tdesign-react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PositionBuiltinAgentTabs,
|
||||
type PositionBuiltinAgentSwitchOption,
|
||||
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
||||
import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput";
|
||||
import {
|
||||
completePositionActionPlan,
|
||||
completePositionSummary,
|
||||
preparePositionActionPlan,
|
||||
preparePositionSummary,
|
||||
savePositionBuiltinConversation,
|
||||
type PositionRoundtableSession,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import {
|
||||
buildConversationSnapshot,
|
||||
mergeConversationMessages,
|
||||
readConversationDraft,
|
||||
readConversationMessages,
|
||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||
import {
|
||||
initPositionActionPlanThread,
|
||||
initSummaryThread,
|
||||
listSessionArtifacts,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
export type PositionBuiltinAgentKind = "summary" | "action_plan";
|
||||
|
||||
interface BuiltinArtifact {
|
||||
name: string;
|
||||
path: string;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
interface PendingBuiltinSubmission {
|
||||
threadId: string;
|
||||
agentId: string;
|
||||
message: PromptInputMessage;
|
||||
prompt: string;
|
||||
sourceRevisions: Record<string, number>;
|
||||
}
|
||||
|
||||
interface PositionBuiltinAgentChatProps {
|
||||
kind: PositionBuiltinAgentKind;
|
||||
taskId: string;
|
||||
session: PositionRoundtableSession | null;
|
||||
allDelivered: boolean;
|
||||
summaryDone: boolean;
|
||||
selectedModel: string;
|
||||
/** Frontend-only demo: render the persisted snapshot without agent lookups. */
|
||||
offlineDemo?: boolean;
|
||||
/** Increments only for an explicit flow/dropdown selection that should begin a fresh first turn. */
|
||||
autoStartRequestKey?: number;
|
||||
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
|
||||
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
|
||||
onSessionUpdate: (session: PositionRoundtableSession) => void;
|
||||
onConversationStart?: () => void;
|
||||
onPreviewArtifact?: (artifact: {
|
||||
threadId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
auto?: boolean;
|
||||
}) => void;
|
||||
onThreadSnapshot?: (snapshot: {
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收口阶段不是业务链里的普通席位,但仍是可独立配置的智能体。
|
||||
* 导出这两个固定 id,供测试分析岗右侧的「智能体配置」精确展示,避免
|
||||
* 又把当前业务链席位误当成收口阶段的配置项。
|
||||
*/
|
||||
export const SUMMARY_AGENT_ID = "roundtable-summary";
|
||||
export const ACTION_PLAN_AGENT_ID = "position-action-planner";
|
||||
|
||||
function builtinAgentId(kind: PositionBuiltinAgentKind) {
|
||||
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
|
||||
}
|
||||
|
||||
function builtinAgentFallbackName(kind: PositionBuiltinAgentKind) {
|
||||
return kind === "summary" ? "方案总结智能体" : "行动规划智能体";
|
||||
}
|
||||
|
||||
function builtinTitle(kind: PositionBuiltinAgentKind) {
|
||||
return kind === "summary" ? "方案总结" : "行动规划";
|
||||
}
|
||||
|
||||
function builtinDescription(kind: PositionBuiltinAgentKind) {
|
||||
return kind === "summary"
|
||||
? "汇总全部岗位交付,形成最终方案建议、关键依据和风险提示。"
|
||||
: "基于方案总结继续拆解行动步骤、责任建议、时间节奏和子任务清单。";
|
||||
}
|
||||
|
||||
function defaultStartText(kind: PositionBuiltinAgentKind) {
|
||||
return kind === "summary"
|
||||
? "请基于当前所有岗位交付,开始生成方案总结。"
|
||||
: "请基于当前方案总结,开始生成行动规划。";
|
||||
}
|
||||
|
||||
function recordString(record: Record<string, unknown> | null | undefined, key: string): string {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function snapshotStatus(snapshot: Record<string, unknown> | null | undefined) {
|
||||
const status = recordString(snapshot, "status");
|
||||
return status || "pending";
|
||||
}
|
||||
|
||||
function snapshotArtifact(
|
||||
snapshot: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): Record<string, unknown> | null {
|
||||
const value = snapshot?.[key];
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function artifactName(artifact: Record<string, unknown> | null | undefined): string {
|
||||
const name = recordString(artifact, "name").trim();
|
||||
const path = recordString(artifact, "path").trim();
|
||||
return name || path.split("/").filter(Boolean).at(-1) || "未命名产物";
|
||||
}
|
||||
|
||||
function artifactPath(artifact: Record<string, unknown> | null | undefined): string {
|
||||
return recordString(artifact, "path").trim();
|
||||
}
|
||||
|
||||
function artifactMimeType(artifact: Record<string, unknown> | null | undefined): string | null {
|
||||
return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null;
|
||||
}
|
||||
|
||||
function readBuiltinArtifacts(
|
||||
snapshot: Record<string, unknown> | null | undefined,
|
||||
): BuiltinArtifact[] {
|
||||
const seen = new Set<string>();
|
||||
return ["primary_artifact", "subtasks_artifact"].flatMap((key) => {
|
||||
const artifact = snapshotArtifact(snapshot, key);
|
||||
const path = artifactPath(artifact);
|
||||
if (!path || seen.has(path)) return [];
|
||||
seen.add(path);
|
||||
return [
|
||||
{
|
||||
path,
|
||||
name: artifactName(artifact),
|
||||
mimeType: artifactMimeType(artifact),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function artifactDisplayName(pathOrUrl: string): string {
|
||||
let path = pathOrUrl;
|
||||
if (pathOrUrl.startsWith("write-file:")) {
|
||||
try {
|
||||
path = decodeURIComponent(new URL(pathOrUrl).pathname);
|
||||
} catch {
|
||||
path = pathOrUrl;
|
||||
}
|
||||
}
|
||||
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
||||
}
|
||||
|
||||
function latestAssistantText(messages: AgentThreadState["messages"]): string {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message?.type !== "ai") continue;
|
||||
const text = textOfMessage(message)?.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function manifestFromArtifacts(
|
||||
files: Awaited<ReturnType<typeof listSessionArtifacts>>["files"],
|
||||
): Array<Record<string, unknown>> {
|
||||
return files.map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
mime_type: file.mimeType,
|
||||
modified_at: file.modifiedAt,
|
||||
size_bytes: file.sizeBytes,
|
||||
}));
|
||||
}
|
||||
|
||||
export function PositionBuiltinAgentChat({
|
||||
kind,
|
||||
taskId,
|
||||
session,
|
||||
allDelivered,
|
||||
summaryDone,
|
||||
selectedModel,
|
||||
offlineDemo = false,
|
||||
autoStartRequestKey,
|
||||
builtinAgentOptions,
|
||||
onSelectBuiltinAgent,
|
||||
onSessionUpdate,
|
||||
onConversationStart,
|
||||
onPreviewArtifact,
|
||||
onThreadSnapshot,
|
||||
}: PositionBuiltinAgentChatProps) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const fallbackAgentId = builtinAgentId(kind);
|
||||
const snapshot =
|
||||
kind === "summary" ? session?.summarySnapshot : session?.actionPlanSnapshot;
|
||||
const persistedThreadId =
|
||||
(kind === "summary" ? session?.summaryThreadId : session?.actionPlanThreadId) ||
|
||||
recordString(snapshot, "thread_id") ||
|
||||
null;
|
||||
const persistedConversation =
|
||||
kind === "summary" ? session?.summaryConversation : session?.actionPlanConversation;
|
||||
const persistedMessages = useMemo(
|
||||
() => readConversationMessages(persistedConversation),
|
||||
[persistedConversation],
|
||||
);
|
||||
const [inputDraft, setInputDraft] = useState(() =>
|
||||
readConversationDraft(persistedConversation),
|
||||
);
|
||||
const snapshotDone = snapshotStatus(snapshot) === "done";
|
||||
const snapshotStale = snapshotStatus(snapshot) === "stale";
|
||||
const [assistantId, setAssistantId] = useState(fallbackAgentId);
|
||||
const { agent } = useAgent(assistantId, { enabled: !offlineDemo });
|
||||
const agentDisplayName = desensitize(agent?.name || builtinAgentFallbackName(kind));
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(persistedThreadId);
|
||||
const boundThreadIdRef = useRef<string | null>(persistedThreadId);
|
||||
const draftThreadIdRef = useRef(uuid());
|
||||
const pendingRunRef = useRef<{
|
||||
threadId: string;
|
||||
sourceRevisions: Record<string, number>;
|
||||
model?: string;
|
||||
} | null>(null);
|
||||
const [pendingSubmission, setPendingSubmission] =
|
||||
useState<PendingBuiltinSubmission | null>(null);
|
||||
const pendingSubmissionRef = useRef(false);
|
||||
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setAssistantId(fallbackAgentId);
|
||||
setBoundThreadId(persistedThreadId);
|
||||
boundThreadIdRef.current = persistedThreadId;
|
||||
setInputDraft(readConversationDraft(persistedConversation));
|
||||
draftThreadIdRef.current = uuid();
|
||||
pendingRunRef.current = null;
|
||||
pendingSubmissionRef.current = false;
|
||||
setPendingSubmission(null);
|
||||
handledAutoStartRequestRef.current = undefined;
|
||||
// Do not depend on `persistedThreadId` here. Preparing the automatic
|
||||
// first question can persist a thread id while the page's initial
|
||||
// session-request is still resolving. Treating that update as a new
|
||||
// session used to clear `pendingSubmission` before the normal send path
|
||||
// ran; a manual click only worked because that request had already ended.
|
||||
}, [fallbackAgentId, kind, session?.id]);
|
||||
|
||||
const canWrite =
|
||||
Boolean(session) &&
|
||||
session?.status !== "archived" &&
|
||||
allDelivered &&
|
||||
(kind === "summary" || summaryDone);
|
||||
|
||||
const readOnlyHint =
|
||||
session?.status === "archived"
|
||||
? "该会话已归档,仅可查看历史对话和产物"
|
||||
: !allDelivered
|
||||
? "所有岗位交付完成后,才能进入收口智能体对话"
|
||||
: kind === "action_plan" && !summaryDone
|
||||
? "请先进入方案总结智能体并完成有效总结,再继续行动规划"
|
||||
: "";
|
||||
|
||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||
threadId: boundThreadId,
|
||||
assistantId,
|
||||
context: {
|
||||
...settings.context,
|
||||
agent_id: assistantId,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
},
|
||||
threadMetadata: {
|
||||
thread_type: "position_roundtable_builtin",
|
||||
taskId,
|
||||
position_roundtable_task_id: taskId,
|
||||
position_session_id: session?.id,
|
||||
position_builtin_kind: kind,
|
||||
agent_id: assistantId,
|
||||
},
|
||||
onStart: (threadId) => {
|
||||
boundThreadIdRef.current = threadId;
|
||||
setBoundThreadId(threadId);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
const pending = pendingRunRef.current;
|
||||
if (!pending || !session) return;
|
||||
const content = latestAssistantText(state.messages ?? []);
|
||||
if (!content) return;
|
||||
pendingRunRef.current = null;
|
||||
void (async () => {
|
||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||
try {
|
||||
const artifacts = await listSessionArtifacts(pending.threadId);
|
||||
artifactManifest = manifestFromArtifacts(artifacts.files);
|
||||
} catch {
|
||||
// Completion still receives the visible answer; it will give a
|
||||
// clearer error if a required deliverable is genuinely missing.
|
||||
}
|
||||
try {
|
||||
const next =
|
||||
kind === "summary"
|
||||
? await completePositionSummary(session.id, {
|
||||
threadId: pending.threadId,
|
||||
content,
|
||||
artifactManifest,
|
||||
sourceRevisions: pending.sourceRevisions,
|
||||
model: pending.model,
|
||||
})
|
||||
: await completePositionActionPlan(session.id, {
|
||||
threadId: pending.threadId,
|
||||
content,
|
||||
artifactManifest,
|
||||
sourceRevisions: pending.sourceRevisions,
|
||||
model: pending.model,
|
||||
});
|
||||
onSessionUpdate(next);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`${builtinTitle(kind)}结果归档失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
const activeThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||
const conversationThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const displayThreadId = boundThreadId ?? `position-builtin-${kind}-${session?.id ?? "draft"}`;
|
||||
const recoveredMessages = useMemo(
|
||||
() => mergeConversationMessages(persistedMessages, thread.messages),
|
||||
[persistedMessages, thread.messages],
|
||||
);
|
||||
const displayThread = useMemo(
|
||||
() =>
|
||||
({
|
||||
...thread,
|
||||
messages: recoveredMessages,
|
||||
}) as BaseStream<AgentThreadState>,
|
||||
[recoveredMessages, thread],
|
||||
);
|
||||
const threadContextValue = useMemo(() => ({ thread: displayThread }), [displayThread]);
|
||||
const isSubmitting = pendingSubmission !== null;
|
||||
const status = thread.error ? "error" : thread.isLoading || isSubmitting ? "streaming" : "ready";
|
||||
const persistedArtifacts = useMemo(() => readBuiltinArtifacts(snapshot), [snapshot]);
|
||||
const pendingConversationRef = useRef<{
|
||||
signature: string;
|
||||
threadId: string | null;
|
||||
snapshot: ReturnType<typeof buildConversationSnapshot>;
|
||||
} | null>(null);
|
||||
const lastPersistedConversationRef = useRef("");
|
||||
const conversationSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flushConversationSnapshot = useCallback(() => {
|
||||
const currentSessionId = session?.id;
|
||||
const pending = pendingConversationRef.current;
|
||||
if (!currentSessionId || !pending) return;
|
||||
pendingConversationRef.current = null;
|
||||
if (conversationSaveTimerRef.current) {
|
||||
clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = null;
|
||||
}
|
||||
lastPersistedConversationRef.current = pending.signature;
|
||||
void savePositionBuiltinConversation(currentSessionId, kind, {
|
||||
threadId: pending.threadId,
|
||||
conversationSnapshot: pending.snapshot,
|
||||
})
|
||||
.then(onSessionUpdate)
|
||||
.catch((error) => {
|
||||
lastPersistedConversationRef.current = "";
|
||||
console.warn(`Failed to persist position ${kind} conversation snapshot`, error);
|
||||
});
|
||||
}, [kind, onSessionUpdate, session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || session.status === "archived") return;
|
||||
const conversationSnapshot = buildConversationSnapshot(recoveredMessages, inputDraft);
|
||||
const signature = JSON.stringify([
|
||||
conversationThreadId,
|
||||
conversationSnapshot.messages,
|
||||
conversationSnapshot.inputDraft ?? "",
|
||||
]);
|
||||
if (signature === lastPersistedConversationRef.current) return;
|
||||
pendingConversationRef.current = {
|
||||
signature,
|
||||
threadId: conversationThreadId,
|
||||
snapshot: conversationSnapshot,
|
||||
};
|
||||
if (conversationSaveTimerRef.current) clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = setTimeout(flushConversationSnapshot, 650);
|
||||
return () => {
|
||||
if (conversationSaveTimerRef.current) {
|
||||
clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
conversationThreadId,
|
||||
flushConversationSnapshot,
|
||||
inputDraft,
|
||||
recoveredMessages,
|
||||
session,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
flushConversationSnapshot();
|
||||
},
|
||||
[flushConversationSnapshot],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
|
||||
boundThreadIdRef.current = actualThreadId;
|
||||
setBoundThreadId(actualThreadId);
|
||||
}, [actualThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
onThreadSnapshot?.({ threadId: activeThreadId, thread });
|
||||
}, [activeThreadId, onThreadSnapshot, thread]);
|
||||
|
||||
const ensureBuiltinThread = useCallback(
|
||||
async (preparedThreadId: string | null, preparedAgentId: string) => {
|
||||
if (preparedThreadId) {
|
||||
return { agentName: preparedAgentId, threadId: preparedThreadId };
|
||||
}
|
||||
if (boundThreadIdRef.current) {
|
||||
return { agentName: preparedAgentId, threadId: boundThreadIdRef.current };
|
||||
}
|
||||
return kind === "summary" ? initSummaryThread() : initPositionActionPlanThread();
|
||||
},
|
||||
[kind],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (!session) {
|
||||
toast.warning("请先启动业务链");
|
||||
return;
|
||||
}
|
||||
if (!canWrite) {
|
||||
toast.info(readOnlyHint || "当前阶段暂不可对话");
|
||||
return;
|
||||
}
|
||||
if (pendingSubmissionRef.current || thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
pendingSubmissionRef.current = true;
|
||||
const visibleText = message.text.trim() || defaultStartText(kind);
|
||||
const followUp = kind === "summary" && !snapshotDone ? "" : visibleText;
|
||||
try {
|
||||
onConversationStart?.();
|
||||
const preparation =
|
||||
kind === "summary"
|
||||
? await preparePositionSummary(session.id, followUp)
|
||||
: await preparePositionActionPlan(session.id, followUp);
|
||||
setAssistantId(preparation.agentId);
|
||||
const builtinThread = await ensureBuiltinThread(preparation.threadId, preparation.agentId);
|
||||
boundThreadIdRef.current = builtinThread.threadId;
|
||||
setBoundThreadId(builtinThread.threadId);
|
||||
pendingRunRef.current = {
|
||||
threadId: builtinThread.threadId,
|
||||
sourceRevisions: preparation.sourceRevisions,
|
||||
model: selectedModel || undefined,
|
||||
};
|
||||
// The standard chat stream must first bind to the prepared thread.
|
||||
// The effect below then submits the same visible message a manual
|
||||
// click would send, so this flow has no separate presentation layer.
|
||||
setPendingSubmission({
|
||||
threadId: builtinThread.threadId,
|
||||
agentId: preparation.agentId,
|
||||
message: { ...message, text: visibleText },
|
||||
prompt: preparation.prompt,
|
||||
sourceRevisions: preparation.sourceRevisions,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pendingRunRef.current = null;
|
||||
pendingSubmissionRef.current = false;
|
||||
toast.error(`发送失败:${message}`);
|
||||
}
|
||||
},
|
||||
[
|
||||
canWrite,
|
||||
ensureBuiltinThread,
|
||||
kind,
|
||||
onConversationStart,
|
||||
readOnlyHint,
|
||||
selectedModel,
|
||||
session,
|
||||
snapshotDone,
|
||||
thread.isLoading,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!pendingSubmission ||
|
||||
thread.isLoading ||
|
||||
actualThreadId !== pendingSubmission.threadId ||
|
||||
assistantId !== pendingSubmission.agentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submission = pendingSubmission;
|
||||
setPendingSubmission(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await sendMessage(
|
||||
submission.threadId,
|
||||
submission.message,
|
||||
{
|
||||
agent_id: submission.agentId,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
},
|
||||
{
|
||||
hiddenContext: {
|
||||
text: submission.prompt,
|
||||
name: "position_builtin_context",
|
||||
additionalKwargs: {
|
||||
position_builtin_kind: kind,
|
||||
position_session_id: session?.id,
|
||||
source_revisions: submission.sourceRevisions,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
pendingRunRef.current = null;
|
||||
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
pendingSubmissionRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [actualThreadId, assistantId, kind, pendingSubmission, selectedModel, sendMessage, session?.id, thread.isLoading]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
pendingSubmissionRef.current = false;
|
||||
pendingRunRef.current = null;
|
||||
setPendingSubmission(null);
|
||||
void thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const handleOpenMessageArtifact = useCallback(
|
||||
(filepath: string) => {
|
||||
onPreviewArtifact?.({
|
||||
threadId: activeThreadId,
|
||||
path: filepath,
|
||||
name: artifactDisplayName(filepath),
|
||||
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
|
||||
});
|
||||
},
|
||||
[activeThreadId, onPreviewArtifact],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!autoStartRequestKey ||
|
||||
handledAutoStartRequestRef.current === autoStartRequestKey ||
|
||||
!canWrite ||
|
||||
snapshotDone ||
|
||||
thread.isLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The request key changes only after an explicit page action. Keeping the
|
||||
// handled key locally prevents route/session re-renders from auto-sending
|
||||
// the first question again.
|
||||
handledAutoStartRequestRef.current = autoStartRequestKey;
|
||||
void handleSubmit({ text: defaultStartText(kind), files: [] });
|
||||
}, [autoStartRequestKey, canWrite, handleSubmit, kind, snapshotDone, thread.isLoading]);
|
||||
|
||||
const Icon = kind === "summary" ? Sparkles : Target;
|
||||
const snapshotLabel = snapshotDone ? "已完成" : snapshotStale ? "需重做" : "待对话";
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={threadContextValue}>
|
||||
<div className="position-roundtable-agent-analysis-page position-roundtable-builtin-chat flex min-h-0 flex-1 flex-col">
|
||||
<header className="position-roundtable-agent-header flex min-h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-2 dark:border-[#2b8ffe]/20">
|
||||
<span className="position-roundtable-agent-avatar flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{canWrite ? <Icon className="size-4" /> : <LockKeyhole className="size-3.5" />}
|
||||
</span>
|
||||
<div className="position-roundtable-agent-title min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{agentDisplayName}
|
||||
</h2>
|
||||
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
|
||||
{readOnlyHint || builtinDescription(kind)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||||
snapshotDone
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: snapshotStale
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]",
|
||||
)}
|
||||
>
|
||||
{snapshotLabel}
|
||||
</span>
|
||||
{builtinAgentOptions && onSelectBuiltinAgent && (
|
||||
<PositionBuiltinAgentTabs
|
||||
activeKind={kind}
|
||||
options={builtinAgentOptions}
|
||||
isStreaming={thread.isLoading}
|
||||
onSelect={onSelectBuiltinAgent}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{persistedArtifacts.length > 0 && (
|
||||
<section className="position-roundtable-agent-artifacts shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
|
||||
<FileText className="size-3.5 text-primary" />
|
||||
阶段产物
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
|
||||
{persistedArtifacts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
|
||||
{persistedArtifacts.map((artifact) => (
|
||||
<button
|
||||
key={artifact.path}
|
||||
type="button"
|
||||
title={`预览 ${artifact.name}`}
|
||||
onClick={() => {
|
||||
if (boundThreadIdRef.current) {
|
||||
onPreviewArtifact?.({
|
||||
threadId: boundThreadIdRef.current,
|
||||
path: artifact.path,
|
||||
name: artifact.name,
|
||||
mimeType: artifact.mimeType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0" />
|
||||
<span className="max-w-48 truncate">{artifact.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{boundThreadId || recoveredMessages.length > 0 ? (
|
||||
<MessageList
|
||||
className="position-roundtable-agent-message-list min-h-0 flex-1"
|
||||
threadId={boundThreadId ?? displayThreadId}
|
||||
thread={displayThread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-6"
|
||||
// Use the built-in agent's regular submit path. This keeps a
|
||||
// clarification answer on the same persisted thread and retains
|
||||
// the summary/action-plan preparation and completion lifecycle.
|
||||
onClarificationSubmit={(answer) =>
|
||||
handleSubmit({ text: answer, files: [] })
|
||||
}
|
||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||
onOpenArtifact={handleOpenMessageArtifact}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
|
||||
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Bot className="size-5" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
|
||||
{canWrite ? `进入${builtinTitle(kind)}智能体对话` : "当前阶段尚未解锁"}
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
|
||||
{canWrite
|
||||
? "你可以直接发送开始生成,也可以先补充口径、约束或输出格式要求。系统会自动带入全部有效岗位产物。"
|
||||
: readOnlyHint}
|
||||
</p>
|
||||
{canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void handleSubmit({ text: defaultStartText(kind), files: [] })}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{kind === "summary" ? "开始方案总结" : "开始行动规划"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||
<PositionAgentInput
|
||||
className="w-full"
|
||||
isNewThread={!boundThreadId}
|
||||
threadId={displayThreadId}
|
||||
assistantId={assistantId}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={!canWrite || isSubmitting}
|
||||
draftKey={`${session?.id ?? "draft"}:${kind}`}
|
||||
draftValue={inputDraft}
|
||||
onDraftChange={setInputDraft}
|
||||
hideAdminRecommendedQuestions
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={(message) => void handleSubmit(message)}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||