Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9be0c6555f | ||
|
|
d86c9d48a6 | ||
|
|
008c9ede6e | ||
|
|
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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,28 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">CM 助手</title>
|
||||
<defs>
|
||||
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5DB5FF"/>
|
||||
<stop offset="1" stop-color="#328AF2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#62B2FF"/>
|
||||
<stop offset="1" stop-color="#4B98F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2C7CF0"/>
|
||||
<stop offset="1" stop-color="#1F5EDA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#276DE3"/>
|
||||
<stop offset="1" stop-color="#4E9DF5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- The mark is drawn as four independent folded faces. Together they form
|
||||
the open C-shaped cube used in the product reference. -->
|
||||
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
|
||||
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
|
||||
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
|
||||
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 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",
|
||||
|
||||
@@ -85,6 +113,13 @@ 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)。
|
||||
|
||||
|
||||
@@ -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", "lightgreen"]}
|
||||
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,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,6 +575,37 @@
|
||||
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)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
||||
.light-sidebar {
|
||||
position: fixed;
|
||||
@@ -587,6 +629,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="收起侧边栏"
|
||||
|
||||
@@ -49,6 +49,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 +81,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 +157,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="二级菜单">
|
||||
@@ -287,6 +298,7 @@ function SecondaryNavSidebar() {
|
||||
function LegacyAccordionSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden,
|
||||
}: PageSidebarProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -438,7 +450,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -27,9 +27,7 @@ import {
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
// Keep the workbench navigation compact across every theme. The visual theme
|
||||
// only changes tokens; the information density should remain consistent.
|
||||
const SIDEBAR_WIDTH = "14rem";
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
@@ -59,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);
|
||||
@@ -94,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",
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentBrandIconProps = {
|
||||
className?: string;
|
||||
size?: "nav" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const SIZE_STYLES = {
|
||||
nav: {
|
||||
frame: "size-[22px] rounded-md",
|
||||
},
|
||||
sm: {
|
||||
frame: "size-10 rounded-full",
|
||||
},
|
||||
md: {
|
||||
frame: "size-12 rounded-full",
|
||||
},
|
||||
lg: {
|
||||
frame: "size-16 rounded-full",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function AgentBrandIcon({
|
||||
className,
|
||||
size = "md",
|
||||
}: AgentBrandIconProps) {
|
||||
const styles = SIZE_STYLES[size];
|
||||
const { theme } = useTheme();
|
||||
const avatarSrc =
|
||||
theme === "light" ? "/agent-avatar-red.png" : "/agent-avatar.png";
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-agent-brand-icon={size}
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 overflow-hidden rounded-full",
|
||||
styles.frame,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt=""
|
||||
className="size-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
import {
|
||||
BotIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
MoreHorizontalIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -38,21 +38,14 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -66,8 +59,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
@@ -556,10 +548,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
我的
|
||||
</Badge>
|
||||
@@ -567,7 +559,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||
>
|
||||
来自他人
|
||||
@@ -576,7 +568,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title="该智能体已对所有用户可见"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
@@ -671,12 +663,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const skillBadgeItems = [
|
||||
...(agent.tool_groups ?? []).map((group) => ({
|
||||
key: `tg:${group}`,
|
||||
node: (
|
||||
<Badge
|
||||
key={`tg:${group}`}
|
||||
variant="outline"
|
||||
className="border-0 bg-primary/5 text-primary hover:bg-primary/10 px-2.5 py-1 text-xs font-normal"
|
||||
>
|
||||
node: (
|
||||
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
),
|
||||
@@ -686,8 +674,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
node: (
|
||||
<Badge
|
||||
key={`sk:${skill}`}
|
||||
variant="secondary"
|
||||
className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer border-0 px-2.5 py-1 text-xs font-normal"
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -835,181 +823,80 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</>
|
||||
);
|
||||
|
||||
const chatButton = (
|
||||
<Button
|
||||
variant={isList ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
isList
|
||||
? "bg-primary hover:bg-primary/90 shrink-0 rounded-xl px-4 font-medium text-primary-foreground"
|
||||
: "border-primary/35 text-primary hover:border-primary/55 hover:bg-primary/5 h-11 w-full rounded-lg bg-white font-medium shadow-none dark:bg-slate-900 dark:text-primary",
|
||||
)}
|
||||
onClick={handleChat}
|
||||
const chatButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(isList ? "shrink-0" : "w-full")}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
开始对话
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const cardStatusBadge = agent.is_private ? (
|
||||
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
|
||||
私密
|
||||
</Badge>
|
||||
) : agent.published || isBuiltin ? (
|
||||
<Badge className="border-0 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-normal dark:bg-primary/15 dark:text-primary">
|
||||
<span className="bg-primary mr-1.5 size-1.5 rounded-full" />
|
||||
已发布
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
|
||||
运行中
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
|
||||
const cardActionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
title="更多操作"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{canFavorite && (
|
||||
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
|
||||
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
|
||||
{isFavorite ? "取消收藏" : "收藏到我的"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
|
||||
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
|
||||
{isPinned ? "取消置顶" : "置顶"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
|
||||
<TagsIcon className="size-4" />
|
||||
{t.adminTags.manageTags}
|
||||
</DropdownMenuItem>
|
||||
{isOwned && (
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
|
||||
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
|
||||
{agent.published ? "取消发布" : "发布"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(isOwned || isAdmin) && (
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
|
||||
<ShieldIcon className="size-4" />
|
||||
{agent.is_private ? "移出私密广场" : "放入私密广场"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
|
||||
<CopyIcon className="size-4" />
|
||||
复制智能体
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
|
||||
<SquarePenIcon className="size-4" />
|
||||
编辑智能体
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
{t.agents.delete}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
);
|
||||
|
||||
const cardLayout = (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
|
||||
agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
|
||||
{agent.featured_order != null ? (
|
||||
<>
|
||||
<div className="mb-1 flex h-7 items-center justify-between">
|
||||
<Badge className="border border-primary/20 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-medium dark:border-primary/40 dark:bg-primary/15 dark:text-primary">
|
||||
<SparklesIcon className="mr-1 size-3.5 fill-current" />
|
||||
推荐
|
||||
</Badge>
|
||||
{cardActionsMenu}
|
||||
</div>
|
||||
<AgentBrandIcon size="lg" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start justify-between">
|
||||
<AgentBrandIcon size="lg" />
|
||||
{cardActionsMenu}
|
||||
</div>
|
||||
)}
|
||||
<CardTitle
|
||||
className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="px-5 pt-0 pb-1.5">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
const cardLayout = (
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="min-w-0 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{desensitize(agent.model)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
||||
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{hasSkillsOrTools && (
|
||||
<CardContent className="px-5 pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-4 px-5 pt-2 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{cardStatusBadge}
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-slate-400"
|
||||
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
|
||||
>
|
||||
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
|
||||
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||
{showActionRow && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{actionButtons}
|
||||
</div>
|
||||
)}
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const listLayout = (
|
||||
<div className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900">
|
||||
<AgentBrandIcon className="agent-avatar" size="md" />
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
@@ -520,7 +520,9 @@ export function AgentDetailPanel({
|
||||
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentBrandIcon className="agent-avatar" size="lg" />
|
||||
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{editing && draft ? (
|
||||
<Input
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDownUpIcon,
|
||||
BotIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,17 +10,17 @@ import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus, usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
type AgentStatusFilter = "all" | "published" | "draft" | "private" | "builtin";
|
||||
|
||||
// Within a single tab the verified password is kept in sessionStorage so a
|
||||
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
|
||||
@@ -41,10 +32,11 @@ export function AgentGallery() {
|
||||
const router = useRouter();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
@@ -61,9 +53,10 @@ export function AgentGallery() {
|
||||
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
|
||||
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
|
||||
// filters change, every tab returns to its first page.
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({});
|
||||
}, [search, sort, publisher?.id, statusFilter]);
|
||||
}, [search, tagKey, sort, publisher?.id]);
|
||||
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the gallery never points at a dead tab.
|
||||
@@ -115,6 +108,7 @@ export function AgentGallery() {
|
||||
} = useAgentsPaginated({
|
||||
scope: mainScope,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -132,6 +126,7 @@ export function AgentGallery() {
|
||||
error: privateError,
|
||||
} = usePrivateAgentsPaginated(privatePassword, {
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: pages.private ?? 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -182,22 +177,21 @@ export function AgentGallery() {
|
||||
const handlePublisherClick = (p: { id: string; label: string }) =>
|
||||
setPublisher(p);
|
||||
|
||||
const filterByStatus = (agent: Agent) => {
|
||||
if (statusFilter === "published") return agent.published && !agent.is_private;
|
||||
if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin;
|
||||
if (statusFilter === "private") return agent.is_private;
|
||||
if (statusFilter === "builtin") return agent.builtin;
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderGrid = (list: Agent[]) => {
|
||||
const visibleAgents = list.filter(filterByStatus);
|
||||
if (visibleAgents.length === 0 && list.length > 0) {
|
||||
return renderEmpty("当前状态筛选下没有智能体。");
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleAgents.map((agent) => (
|
||||
const renderGrid = (list: Agent[]) =>
|
||||
viewMode === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
layout="list"
|
||||
onPublisherClick={handlePublisherClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
@@ -206,7 +200,6 @@ export function AgentGallery() {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
||||
const renderListBody = (opts: {
|
||||
@@ -271,17 +264,20 @@ export function AgentGallery() {
|
||||
};
|
||||
|
||||
const renderEmpty = (description: string, showNewButton = false) => (
|
||||
<div className="flex h-72 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-primary/25 bg-white/70 text-center shadow-[0_12px_36px_rgba(15,23,42,0.05)] dark:border-primary/40 dark:bg-slate-900/70">
|
||||
<AgentBrandIcon size="lg" />
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{showNewButton && (
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 mt-2 rounded-xl px-5 text-primary-foreground shadow-[0_8px_20px_rgba(15,23,42,0.16)]"
|
||||
variant="outline"
|
||||
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
|
||||
onClick={handleNewAgent}
|
||||
>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
@@ -368,22 +364,17 @@ export function AgentGallery() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-white dark:bg-slate-950">
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex shrink-0 items-start justify-between px-5 pt-7 pb-6 sm:px-8">
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-[26px] leading-none font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
|
||||
{t.agents.title}
|
||||
</h1>
|
||||
<p className="mt-2.5 text-[15px] text-slate-500 dark:text-slate-400">
|
||||
创建并管理专属的 AI 智能体,助力更高效的工作与决策。
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNewAgent}
|
||||
className="bg-primary hover:bg-primary/90 h-11 w-[148px] rounded-xl px-0 text-[15px] font-medium text-primary-foreground shadow-[0_9px_22px_rgba(15,23,42,0.16)]"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-[18px] w-[18px]" />
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -393,99 +384,43 @@ export function AgentGallery() {
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v);
|
||||
// Each tab starts from a clean slate instead of carrying filters over.
|
||||
// Each tab starts from a clean slate: switching tabs clears the
|
||||
// keyword / tag / sort / publisher filters instead of carrying them
|
||||
// over. The TagSearchBar's own input box is reset via its key below.
|
||||
setSearch("");
|
||||
setTagIds([]);
|
||||
setSort("time_desc");
|
||||
setStatusFilter("all");
|
||||
setPublisher(null);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="shrink-0 px-5 sm:px-8">
|
||||
<div className="flex min-h-12 flex-col gap-3 border-b border-slate-200/90 lg:flex-row lg:items-end lg:justify-between dark:border-slate-800">
|
||||
<TabsList variant="line" className="h-auto w-auto max-w-full flex-wrap justify-start self-start rounded-none border-0 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="mine"
|
||||
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-3 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
|
||||
>
|
||||
我的
|
||||
</TabsTrigger>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
title={sq.description || sq.name}
|
||||
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
|
||||
>
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{privateFeatureAvailable && (
|
||||
<TabsTrigger
|
||||
value="private"
|
||||
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
|
||||
>
|
||||
<TabsTrigger value="private">
|
||||
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||
私密广场
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<TabsTrigger
|
||||
value="builtin"
|
||||
title="内置/功能型智能体(仅管理员可见)"
|
||||
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
|
||||
>
|
||||
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
||||
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
||||
内置智能体
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{showSearchBar && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 pb-2">
|
||||
<div className="relative w-[320px] max-w-full">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3.5 size-[18px] -translate-y-1/2 text-slate-500" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索智能体名称或描述"
|
||||
className="h-11 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-primary/50 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
{(activeTab === "private" ? isPrivateFetching : mainFetching) && (
|
||||
<Loader2 className="text-primary absolute top-1/2 right-3.5 size-4 -translate-y-1/2 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as AgentStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-[150px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
<SelectItem value="draft">未发布</SelectItem>
|
||||
<SelectItem value="private">私密</SelectItem>
|
||||
<SelectItem value="builtin">内置</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
||||
<SelectTrigger
|
||||
className="h-11 w-[150px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="mr-1 size-4 text-slate-500" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">更新时间</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="updated_desc">最近更新</SelectItem>
|
||||
<SelectItem value="updated_asc">最早更新</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
共 {activeTotal} 个
|
||||
</span>
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
{activeTab === "private" && privatePassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -497,16 +432,48 @@ export function AgentGallery() {
|
||||
锁定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showSearchBar && (
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
targetType="agent"
|
||||
searching={
|
||||
activeTab === "private" ? isPrivateFetching : mainFetching
|
||||
}
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="updated_desc">最近更新</SelectItem>
|
||||
<SelectItem value="updated_asc">最早更新</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPublisher(null)}
|
||||
className="bg-primary/5 text-primary hover:bg-primary/10 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors dark:bg-primary/15 dark:text-primary"
|
||||
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
||||
title="点击清除发布者筛选"
|
||||
>
|
||||
{publisher.label}
|
||||
@@ -514,16 +481,13 @@ export function AgentGallery() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-4 text-[15px] text-slate-600 dark:text-slate-300">
|
||||
共 {activeTotal} 个智能体
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsContent
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
@@ -536,7 +500,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
<TabsContent value="mine" className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8">
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
@@ -549,7 +513,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="builtin" className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8">
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
@@ -564,7 +528,7 @@ export function AgentGallery() {
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{renderPrivateTab()}
|
||||
</TabsContent>
|
||||
|
||||
@@ -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";
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { env } from "@/env";
|
||||
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";
|
||||
|
||||
@@ -27,11 +28,13 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
*/
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||
|
||||
// 换肤色板:红色=红白主题(light),绿色=WeKnora 绿色主题(lightgreen),蓝色=浅蓝主题(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" },
|
||||
];
|
||||
|
||||
@@ -80,11 +83,24 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
|
||||
}
|
||||
|
||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR) return null;
|
||||
|
||||
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) {
|
||||
@@ -99,6 +115,8 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
@@ -121,9 +139,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
|
||||
@@ -184,13 +202,13 @@ export function LayoutModeSwitcher({
|
||||
fallback?: "full" | "compact";
|
||||
navigateTo?: string | null;
|
||||
}) {
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR) return null;
|
||||
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
const navigate = useNavigate();
|
||||
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,220 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BookOpenCheck,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
PanelLeftClose,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { AgentBrandIcon } from "./agents/agent-brand-icon";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: typeof MessageSquare;
|
||||
match: string;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH },
|
||||
{ label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" },
|
||||
{ label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" },
|
||||
{ label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" },
|
||||
{ label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" },
|
||||
{ label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" },
|
||||
{
|
||||
label: "多智能体规划",
|
||||
href: "/page/workspace/roundtable/planning",
|
||||
icon: UsersRound,
|
||||
match: "/page/workspace/roundtable",
|
||||
},
|
||||
{
|
||||
label: "定时任务",
|
||||
href: "/page/workspace/scheduled/tasks",
|
||||
icon: CalendarClock,
|
||||
match: "/page/workspace/scheduled",
|
||||
},
|
||||
{ label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" },
|
||||
] as const;
|
||||
|
||||
export function CmChatSidebar({
|
||||
onCollapse,
|
||||
collapsed = false,
|
||||
forceCollapsed = false,
|
||||
}: {
|
||||
onCollapse?: () => void;
|
||||
collapsed?: boolean;
|
||||
/** Deployment-level compact navigation: retain only accessible menu icons. */
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const brandLogoSrc = theme === "light" ? "/cm-assistant-logo-red.png" : "/cm-assistant-logo.png";
|
||||
|
||||
const handleNewChat = (event: 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() } });
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
|
||||
{!forceCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
aria-label="展开侧边栏"
|
||||
title="展开侧边栏"
|
||||
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900"
|
||||
>
|
||||
<img
|
||||
src={brandLogoSrc}
|
||||
alt="CM 助手"
|
||||
className="size-10 object-contain"
|
||||
/>
|
||||
</button>
|
||||
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-1 px-2 py-3",
|
||||
forceCollapsed && "pt-4",
|
||||
)}
|
||||
aria-label="工作台导航"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === NEW_CHAT_PATH
|
||||
? pathname === NEW_CHAT_PATH
|
||||
: item.href === "/page/workspace/chats"
|
||||
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
|
||||
: pathname.startsWith(item.match);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.href === "/page/workspace/agents" ? (
|
||||
<AgentBrandIcon size="nav" />
|
||||
) : (
|
||||
<Icon className="size-[19px]" strokeWidth={1.8} />
|
||||
)}
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
|
||||
<Link
|
||||
href="/page/workspace/settings"
|
||||
aria-label="设置和更多"
|
||||
title="设置和更多"
|
||||
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
>
|
||||
<Settings className="size-[19px]" strokeWidth={1.8} />
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-4 pt-3.5 pb-2.5">
|
||||
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-primary/40">
|
||||
<img
|
||||
src={brandLogoSrc}
|
||||
alt="CM 助手"
|
||||
className="size-9 shrink-0 object-contain"
|
||||
/>
|
||||
<span className="truncate text-[19px] font-semibold tracking-[-0.025em]">CM 助手</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起侧边栏"
|
||||
title="收起侧边栏"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
>
|
||||
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="shrink-0 px-3 pt-1" aria-label="工作台导航">
|
||||
<div className="space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === NEW_CHAT_PATH
|
||||
? pathname === NEW_CHAT_PATH
|
||||
: item.href === "/page/workspace/chats"
|
||||
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
|
||||
: pathname.startsWith(item.match);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-3 rounded-xl px-3 text-[14px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.href === "/page/workspace/agents" ? (
|
||||
<AgentBrandIcon size="nav" />
|
||||
) : (
|
||||
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
|
||||
<WorkspaceNavMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,18 +14,63 @@ 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(
|
||||
"mx-auto flex w-full flex-col items-center justify-center text-center",
|
||||
"mx-auto flex w-full flex-col items-center justify-center text-center select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h1 className="flex items-center justify-center text-[34px] font-semibold tracking-[-0.035em] text-slate-950 dark:text-slate-50">
|
||||
你好,欢迎使用 CM 助手
|
||||
{/* 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={{
|
||||
backgroundColor:
|
||||
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
|
||||
border:
|
||||
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
{desensitizeText("欢迎使用CM助手")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-2.5 max-w-xl text-[15px] leading-6 text-slate-500 dark:text-slate-400">
|
||||
我可以帮你写作、问答、分析和规划各类内容,让工作更高效
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,12 +46,10 @@ export function CustomPromptBar({
|
||||
onInsert,
|
||||
className,
|
||||
currentAgentId,
|
||||
compact = false,
|
||||
}: {
|
||||
onInsert: (content: string) => void;
|
||||
className?: string;
|
||||
currentAgentId?: string | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const allPrompts = useCustomPrompts();
|
||||
const prompts = allPrompts.filter(
|
||||
@@ -83,13 +81,7 @@ export function CustomPromptBar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1",
|
||||
compact && "min-h-9 justify-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
@@ -119,7 +111,6 @@ export function CustomPromptBar({
|
||||
title={prompt.content}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
|
||||
compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-primary/25 hover:bg-primary/5 hover:text-primary",
|
||||
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
@@ -148,11 +139,7 @@ export function CustomPromptBar({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
|
||||
compact &&
|
||||
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-primary/25 hover:bg-primary/5 hover:text-primary dark:border-slate-700 dark:bg-slate-900",
|
||||
)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||
title="管理常用提示词"
|
||||
aria-label="管理常用提示词"
|
||||
onClick={() => setManageOpen(true)}
|
||||
|
||||
@@ -318,7 +318,7 @@ export function GeneralQaSidebar() {
|
||||
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 +342,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 +366,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>
|
||||
@@ -464,9 +467,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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
ClipboardCheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShrinkIcon,
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
ShrinkIcon,
|
||||
SparklesIcon,
|
||||
RocketIcon,
|
||||
XIcon,
|
||||
@@ -106,58 +102,7 @@ import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const COMPACT_QUICK_STARTS = [
|
||||
{
|
||||
id: "report",
|
||||
title: "撰写报告",
|
||||
content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。",
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
{
|
||||
id: "knowledge",
|
||||
title: "知识问答",
|
||||
content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。",
|
||||
icon: MessageCircleQuestionIcon,
|
||||
},
|
||||
{
|
||||
id: "planning",
|
||||
title: "任务规划",
|
||||
content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。",
|
||||
icon: ClipboardCheckIcon,
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
title: "数据分析",
|
||||
content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{
|
||||
id: "solution",
|
||||
title: "方案策划",
|
||||
content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。",
|
||||
icon: LightbulbIcon,
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
title: "内容总结",
|
||||
content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。",
|
||||
icon: BookMarkedIcon,
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
title: "调研提纲",
|
||||
content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。",
|
||||
icon: GraduationCapIcon,
|
||||
},
|
||||
{
|
||||
id: "polish",
|
||||
title: "文案润色",
|
||||
content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。",
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
] as const;
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
@@ -265,41 +210,17 @@ export function InputBox({
|
||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||
const wasStreamingRef = useRef(false);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
const compactQuickStarts = useMemo(() => {
|
||||
if (!hideAdminRecommendedQuestions && recommendedQuestions.length > 0) {
|
||||
return recommendedQuestions.map((configured, index) => {
|
||||
const fallback =
|
||||
COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!;
|
||||
return {
|
||||
...fallback,
|
||||
id: configured.id,
|
||||
title: desensitize(configured.title) || `提示词 ${index + 1}`,
|
||||
content: configured.content || fallback.content,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [...COMPACT_QUICK_STARTS];
|
||||
}, [hideAdminRecommendedQuestions, recommendedQuestions]);
|
||||
const orderedCompactQuickStarts = useMemo(() => {
|
||||
if (compactQuickStarts.length < 2) return compactQuickStarts;
|
||||
const offset = compactPromptOrder % compactQuickStarts.length;
|
||||
return [
|
||||
...compactQuickStarts.slice(offset),
|
||||
...compactQuickStarts.slice(0, offset),
|
||||
];
|
||||
}, [compactPromptOrder, compactQuickStarts]);
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -331,11 +252,10 @@ export function InputBox({
|
||||
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
const effectiveMode = getResolvedMode(context.mode, supportThinking);
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const supportReasoningEffort = useMemo(
|
||||
() => selectedModel?.supports_reasoning_effort ?? false,
|
||||
@@ -609,68 +529,14 @@ export function InputBox({
|
||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={promptRootRef}
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6",
|
||||
)}
|
||||
>
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
|
||||
<section className="px-2" aria-labelledby="recommended-prompts-title">
|
||||
<div className="mb-3 flex items-center justify-center gap-1.5">
|
||||
<h2
|
||||
id="recommended-prompts-title"
|
||||
className="text-[14px] font-normal text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
你可以这样问我
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-prompt-order-refresh="true"
|
||||
title="调整提示词顺序"
|
||||
aria-label="调整提示词顺序"
|
||||
onClick={() => setCompactPromptOrder((current) => current + 1)}
|
||||
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:hover:bg-slate-800 dark:hover:text-primary"
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
|
||||
{orderedCompactQuickStarts.map((item) => {
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-quick-prompt={item.id}
|
||||
title={item.content}
|
||||
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/5 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-primary/35 dark:hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
textInput.setInput(item.content);
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
.querySelector<HTMLTextAreaElement>(
|
||||
"textarea[name='message']",
|
||||
)
|
||||
?.focus(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="max-w-full whitespace-normal">{item.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -721,10 +587,9 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
compact={compact}
|
||||
className={cn(compact ? "px-2" : "px-1")}
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
className="px-1"
|
||||
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
|
||||
onInsert={(content) => {
|
||||
const current = textInput.value?.trim() ?? "";
|
||||
@@ -747,8 +612,8 @@ export function InputBox({
|
||||
? "*:data-[slot='input-group']:rounded-2xl *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:bg-transparent *:data-[slot='input-group']:shadow-none"
|
||||
: "*:data-[slot='input-group']:rounded-3xl",
|
||||
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
||||
compact && isNewThread
|
||||
? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(15,23,42,0.22),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-primary/35 focus-within:ring-4 focus-within:ring-primary/10 dark:border-slate-700 dark:bg-slate-800"
|
||||
compact && isNewThread
|
||||
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
|
||||
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
@@ -772,8 +637,7 @@ export function InputBox({
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
compact &&
|
||||
"min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
|
||||
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -784,7 +648,7 @@ export function InputBox({
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-6 pt-2 pb-5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
@@ -844,7 +708,7 @@ export function InputBox({
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-primary/35 bg-primary/5 text-primary ring-1 ring-primary/20 hover:bg-primary/10 dark:border-primary/45 dark:bg-primary/15 dark:text-primary dark:ring-primary/25 dark:hover:bg-primary/20"
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
@@ -852,7 +716,7 @@ export function InputBox({
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="text-primary size-3" />
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
@@ -919,47 +783,52 @@ export function InputBox({
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
effectiveMode
|
||||
}
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
context.mode === "thinking" ||
|
||||
context.mode === "pro" ||
|
||||
context.mode === "ultra"
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
>
|
||||
<PromptInputActionMenuTrigger
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(effectiveMode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
: effectiveMode === "pro"
|
||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: effectiveMode === "ultra"
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
(context.mode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
: context.mode === "pro"
|
||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: context.mode === "ultra"
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{effectiveMode === "flash" && <ZapIcon className="size-3" />}
|
||||
{effectiveMode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
)}
|
||||
{effectiveMode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{effectiveMode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
effectiveMode === "ultra" ? "golden-text" : "",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
)}
|
||||
>
|
||||
{(effectiveMode === "flash" && t.inputBox.flashMode) ||
|
||||
(effectiveMode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(effectiveMode === "pro" && t.inputBox.proMode) ||
|
||||
(effectiveMode === "ultra" && t.inputBox.ultraMode)}
|
||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide>
|
||||
@@ -1255,10 +1124,8 @@ export function InputBox({
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton
|
||||
disabled={models.length === 0}
|
||||
aria-label="模型选择"
|
||||
className={cn(
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
@@ -1271,7 +1138,7 @@ export function InputBox({
|
||||
compact && "text-[11px]",
|
||||
)}
|
||||
>
|
||||
{selectedModel?.display_name || "默认模型"}
|
||||
{selectedModel?.display_name}
|
||||
</ModelSelectorName>
|
||||
</div>
|
||||
</PromptInputButton>
|
||||
@@ -1303,9 +1170,9 @@ export function InputBox({
|
||||
</ModelSelector>
|
||||
<PromptInputSubmit
|
||||
className={cn(
|
||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-primary dark:border-primary dark:text-primary-foreground dark:hover:bg-primary/90 dark:shadow-none",
|
||||
compact &&
|
||||
"bg-primary hover:bg-primary/90 size-10 rounded-full border-0 text-primary-foreground shadow-[0_8px_18px_-10px_rgba(15,23,42,0.34)] disabled:bg-muted disabled:text-muted-foreground/40 dark:border-0 dark:bg-primary dark:text-primary-foreground dark:hover:bg-primary/90 dark:disabled:bg-muted",
|
||||
"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(--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"
|
||||
|
||||
@@ -65,10 +65,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import {
|
||||
isWeKnoraSourcePreviewUrl,
|
||||
SourceCard,
|
||||
} from "../skill-display-sources";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
@@ -642,43 +639,19 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
let sourceLabel: string | undefined = item.source;
|
||||
if (item.url) {
|
||||
try {
|
||||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
|
||||
// MCP retrieval tools without a configured display adapter fall back to this
|
||||
// generic result card. Preserve the normal web-search UI, but route our
|
||||
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
|
||||
// deliberate “查看原文” action rather than rendering the internal route text.
|
||||
if (isWeKnoraSourcePreviewUrl(item.url)) {
|
||||
const weknoraSource: RuntimeDisplaySource = {
|
||||
id: item.id ?? item.uuid ?? item.docId,
|
||||
title: item.title ?? "检索结果",
|
||||
type: item.source ?? "document",
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content,
|
||||
source: item.source,
|
||||
author: item.author,
|
||||
time: date,
|
||||
url: item.url,
|
||||
index: 0,
|
||||
skillName: "weknora-knowledge-search",
|
||||
mode: "list",
|
||||
raw: { ...item },
|
||||
};
|
||||
return <SourceCard source={weknoraSource} />;
|
||||
}
|
||||
|
||||
return (
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
ListIcon,
|
||||
MoonIcon,
|
||||
PanelLeftIcon,
|
||||
PaletteIcon,
|
||||
Plus,
|
||||
SnowflakeIcon,
|
||||
SunIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -19,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";
|
||||
@@ -62,9 +64,10 @@ 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 },
|
||||
@@ -175,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 =
|
||||
@@ -213,19 +228,23 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
};
|
||||
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue" | "lightgreen",
|
||||
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow",
|
||||
) => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
const label =
|
||||
const label =
|
||||
value === "light"
|
||||
? "浅色"
|
||||
: value === "dark"
|
||||
? "深色"
|
||||
: value === "lightgreen"
|
||||
? "绿色"
|
||||
: "浅蓝";
|
||||
: value === "iceblue"
|
||||
? "冰川蓝"
|
||||
: value === "lightyellow"
|
||||
? "浅黄色"
|
||||
: "浅蓝";
|
||||
toast.success(`已将系统默认主题设置为${label}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -377,8 +396,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
},
|
||||
{
|
||||
id: "lightblue",
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
icon: DropletIcon,
|
||||
},
|
||||
{
|
||||
@@ -387,6 +406,18 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
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,
|
||||
@@ -397,6 +428,10 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -407,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" | "lightgreen"}
|
||||
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 />
|
||||
@@ -576,22 +627,60 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<LeafIcon className="mr-1.5 size-4" />
|
||||
绿色
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light"
|
||||
? "浅色"
|
||||
: globalDefault === "dark"
|
||||
? "深色"
|
||||
: globalDefault === "lightgreen"
|
||||
? "绿色"
|
||||
: "浅蓝"}
|
||||
</p>
|
||||
<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
|
||||
@@ -1092,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" | "lightgreen";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen") => 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"
|
||||
@@ -1130,27 +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"
|
||||
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"
|
||||
: "border-slate-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"
|
||||
: previewMode === "lightgreen"
|
||||
? "bg-[#07c05f]"
|
||||
: "bg-[#cf1322]",
|
||||
)}
|
||||
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,753 @@
|
||||
"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: "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)] bg-[var(--background)] px-3 py-2">
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[var(--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>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,10 @@ import {
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -51,6 +50,13 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemTitle,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -464,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} />
|
||||
@@ -491,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"
|
||||
@@ -736,9 +747,9 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(isCard && "truncate font-medium")}>
|
||||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||
</span>
|
||||
{skill.name_zh?.trim() && (
|
||||
@@ -762,7 +773,7 @@ export function SkillRow({
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary text-xs dark:text-primary"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={
|
||||
skill.owner_user_id
|
||||
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||
@@ -896,12 +907,12 @@ export function SkillRow({
|
||||
</>
|
||||
);
|
||||
|
||||
const actionButtons = (
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
@@ -911,8 +922,8 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setFeatured.isPending}
|
||||
@@ -932,8 +943,8 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
@@ -953,8 +964,8 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.always_on && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setAlwaysOn.isPending}
|
||||
@@ -991,9 +1002,9 @@ export function SkillRow({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
onClick={handleOpenDuplicate}
|
||||
title="复制为新技能"
|
||||
@@ -1002,9 +1013,9 @@ export function SkillRow({
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
@@ -1012,124 +1023,41 @@ export function SkillRow({
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
|
||||
? {
|
||||
Icon: GlobeIcon,
|
||||
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
|
||||
}
|
||||
: skill.name.includes("doc") || skill.name.includes("file")
|
||||
? {
|
||||
Icon: FileArchive,
|
||||
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
|
||||
}
|
||||
: skill.source === "agent"
|
||||
? {
|
||||
Icon: WorkflowIcon,
|
||||
className: "bg-primary text-primary-foreground shadow-[0_6px_16px_rgba(15,23,42,0.18)]",
|
||||
}
|
||||
: {
|
||||
Icon: Upload,
|
||||
className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]",
|
||||
};
|
||||
const SkillIcon = iconStyle.Icon;
|
||||
const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at);
|
||||
const actionArea = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1" onClick={(event) => event.stopPropagation()}>
|
||||
{actionButtons}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/[0.025] hover:shadow-[0_10px_28px_rgba(15,23,42,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||
<SkillIcon className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{badgeGroup}
|
||||
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
|
||||
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{skill.enabled ? "已启用" : "未启用"}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
|
||||
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
|
||||
</div>
|
||||
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
|
||||
</div>
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
|
||||
{actionArea}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group/skill-card w-full rounded-2xl border border-slate-200 bg-white px-5 py-3.5 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/[0.025] hover:shadow-[0_10px_28px_rgba(15,23,42,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.55fr)_minmax(160px,0.78fr)_minmax(110px,0.42fr)_minmax(142px,0.56fr)_auto] xl:items-center xl:gap-x-5">
|
||||
<div className="flex min-w-0 cursor-pointer items-start gap-3.5" onClick={() => setDetailOpen(true)}>
|
||||
<div className={cn("flex size-14 shrink-0 items-center justify-center rounded-2xl", iconStyle.className)}>
|
||||
<SkillIcon className="size-7" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{badgeGroup}
|
||||
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{!isBuiltin && skill.owner_username && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!skill.owner_user_id || !onPublisherClick}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!skill.owner_user_id || !onPublisherClick) return;
|
||||
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
|
||||
}}
|
||||
className={cn(
|
||||
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
|
||||
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-primary hover:underline",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
|
||||
{(skill.tags?.length ?? 0) > 0 ? (
|
||||
<TagChipList tags={skill.tags!} />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">未设置标签</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
|
||||
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
|
||||
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{skill.enabled ? "已启用" : "未启用"}
|
||||
</Badge>
|
||||
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
|
||||
</div>
|
||||
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
|
||||
{actionArea}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{badgeGroup}
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{metaNodes}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{metaNodes}
|
||||
</ItemContent>
|
||||
<ItemActions>{actionButtons}</ItemActions>
|
||||
</Item>
|
||||
)}
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -53,7 +53,6 @@ export function SourceCard({
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
@@ -82,7 +81,7 @@ export function SourceCard({
|
||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !showOriginal || !previewTarget) {
|
||||
if (!open || !previewTarget) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -116,7 +115,7 @@ export function SourceCard({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,10 +128,7 @@ export function SourceCard({
|
||||
highlighted &&
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowOriginal(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{showReferenceNumber && (
|
||||
@@ -172,15 +168,7 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setShowOriginal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
@@ -217,27 +205,7 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{previewTarget && (
|
||||
<div className="border-b pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{showOriginal
|
||||
? "正在查看命中文段及其前后上下文"
|
||||
: "当前为检索命中的片段,可继续查看原文上下文"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={showOriginal ? "outline" : "default"}
|
||||
onClick={() => setShowOriginal((current) => !current)}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
{showOriginal ? "返回检索片段" : "查看原文"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewTarget && showOriginal ? (
|
||||
{previewTarget ? (
|
||||
<WeKnoraSourceContextPanel
|
||||
context={context}
|
||||
loading={contextLoading}
|
||||
@@ -281,27 +249,12 @@ type WeKnoraSourceContext = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
|
||||
return parseWeKnoraPreviewUrl(url) !== null;
|
||||
}
|
||||
|
||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||
const normalizedUrl = url
|
||||
?.trim()
|
||||
.replace(/^[\u200B-\u200D\uFEFF]+/, "");
|
||||
const marker = "/weknora-source-preview?";
|
||||
const start = normalizedUrl?.indexOf(marker) ?? -1;
|
||||
if (start < 0) {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
}
|
||||
// Some MCP tool results wrap the relative pointer in formatting characters
|
||||
// or append a newline. Keep accepting that safe internal path, but never
|
||||
// treat a normal external URL as an original-document preview.
|
||||
const previewUrl = normalizedUrl!
|
||||
.slice(start)
|
||||
.split(/[\r\n\s]/, 1)[0];
|
||||
const queryIndex = previewUrl.indexOf("?");
|
||||
const params = new URLSearchParams(previewUrl.slice(queryIndex + 1));
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { UserRound } from "lucide-react";
|
||||
import { User } from "lucide-react";
|
||||
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useMyPosition } from "@/core/positions";
|
||||
@@ -15,13 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
||||
* 而非 Header 的白字贴 banner 方案。
|
||||
*/
|
||||
export function UserIdentityBadge({
|
||||
className,
|
||||
variant = "plain",
|
||||
}: {
|
||||
className?: string;
|
||||
variant?: "plain" | "topbar";
|
||||
}) {
|
||||
export function UserIdentityBadge({ className }: { className?: string }) {
|
||||
// userInfo belongs to external-app route parameters and can legitimately
|
||||
// outlive a login switch. The visible account name must instead match the
|
||||
// settings page's current auth identity.
|
||||
@@ -32,33 +26,13 @@ export function UserIdentityBadge({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-workspace-user={variant}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
variant === "topbar" &&
|
||||
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex shrink-0 items-center gap-2", className)}
|
||||
title={positionName ? `${displayName}·${positionName}` : displayName}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
variant === "topbar" &&
|
||||
"bg-primary text-primary-foreground size-8 shadow-[0_4px_12px_-6px_rgba(15,23,42,0.26)] ring-1 ring-primary/15",
|
||||
)}
|
||||
>
|
||||
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
|
||||
{variant === "topbar" && (
|
||||
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
|
||||
)}
|
||||
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
|
||||
<User className="size-3.5" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"hidden min-w-0 text-left sm:block",
|
||||
variant === "topbar" && "max-w-[165px]",
|
||||
)}
|
||||
>
|
||||
<div className="hidden min-w-0 text-left sm:block">
|
||||
<p className="text-foreground truncate text-sm font-medium leading-tight">
|
||||
{displayName}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,11 +12,8 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspaceContainer({
|
||||
className,
|
||||
@@ -96,19 +93,9 @@ export function WorkspaceHeader({
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
{rightActions}
|
||||
{!env.FORCE_COLLAPSED_SIDEBAR && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UserIdentityBadge variant="topbar" />
|
||||
</div>
|
||||
{rightActions ? (
|
||||
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -136,8 +123,7 @@ function nameOfSegment(
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
if (!segment) return t.common.home;
|
||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||
if (segment === "chats") return t.breadcrumb.chats;
|
||||
if (segment === "agents") return t.agents.title;
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||
if (segment === "chats") return t.breadcrumb.chats;
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -1,52 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CmChatSidebar } from "./cm-chat-sidebar";
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
className,
|
||||
compactChrome: _compactChrome,
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen, toggleSidebar } = useSidebar();
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={false}
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full border-r border-border",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
||||
"[&_[data-slot=sidebar-container]]:bg-white [&_[data-slot=sidebar-inner]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<CmChatSidebar
|
||||
onCollapse={forceCollapsed ? undefined : toggleSidebar}
|
||||
collapsed={!isSidebarOpen}
|
||||
forceCollapsed={forceCollapsed}
|
||||
/>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' | 'lightgreen';
|
||||
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: '深色主题',
|
||||
@@ -126,6 +133,52 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
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';
|
||||
@@ -136,14 +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 (saved === 'lightgreen') return 'lightgreen';
|
||||
if (isBuiltinTheme(saved)) return saved;
|
||||
if (isCustomThemeId(saved)) return saved;
|
||||
return 'light';
|
||||
}
|
||||
|
||||
@@ -184,7 +253,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);
|
||||
@@ -198,13 +269,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' &&
|
||||
current !== 'lightgreen'
|
||||
) {
|
||||
if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -235,21 +300,47 @@ 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'
|
||||
: effectiveTheme === 'lightgreen'
|
||||
? 'lightgreen'
|
||||
: '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';
|
||||
|
||||
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');
|
||||
@@ -260,12 +351,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
root.removeAttribute('theme-mode');
|
||||
root.classList.remove('dark');
|
||||
|
||||
if (theme === 'lightblue' || theme === 'lightgreen') {
|
||||
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');
|
||||
@@ -277,7 +373,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
markExplicitChoice();
|
||||
};
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
@@ -303,6 +399,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}`;
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
|
||||
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
|
||||
import type { ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
export const EXTERNAL_LOGIN_STORAGE_KEYS = {
|
||||
ticket: "login.ticket",
|
||||
@@ -59,21 +58,6 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
|
||||
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
|
||||
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
|
||||
*/
|
||||
export function resolveThemeFromQuery(themeParam: string | null | undefined): ThemeType {
|
||||
switch (themeParam?.trim().toLowerCase()) {
|
||||
case "dark":
|
||||
case "dark-blue":
|
||||
case "darkblue":
|
||||
return "dark";
|
||||
case "blue":
|
||||
case "lightblue":
|
||||
case "light-blue":
|
||||
return "lightblue";
|
||||
case "green":
|
||||
case "lightgreen":
|
||||
case "light-green":
|
||||
return "lightgreen";
|
||||
default:
|
||||
return "light";
|
||||
}
|
||||
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
|
||||
return themeParam === "dark-blue" ? "dark" : "light";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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.
|
||||
*/
|
||||
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = {
|
||||
a: "/page/canvas/ai-writing",
|
||||
"8bf": "/page/strategy/qa/8bf-roundtable",
|
||||
};
|
||||
|
||||
/** 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 resolveRouteKey(routeKey: string | null | undefined): string {
|
||||
const key = routeKey?.trim();
|
||||
if (!key) return "";
|
||||
return ROUTE_KEY_MAPPINGS[key] ?? "";
|
||||
}
|
||||
@@ -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,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import type { ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
/**
|
||||
* 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。
|
||||
@@ -64,25 +63,19 @@ function readFromSearch(search: string): PublicBackground {
|
||||
*/
|
||||
export function normalizePublicTheme(
|
||||
raw: string | null | undefined,
|
||||
): ThemeType | undefined {
|
||||
): "dark" | "light" | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return undefined;
|
||||
if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") {
|
||||
return "dark";
|
||||
}
|
||||
if (value === "blue" || value === "lightblue" || value === "light-blue" || value === "浅蓝") {
|
||||
return "lightblue";
|
||||
}
|
||||
if (value === "green" || value === "lightgreen" || value === "light-green" || value === "绿色") {
|
||||
return "lightgreen";
|
||||
}
|
||||
if (value === "light" || value === "red" || value === "红白" || value === "浅色") {
|
||||
return "light";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readThemeFromSearch(search: string): ThemeType | undefined {
|
||||
function readThemeFromSearch(search: string): "dark" | "light" | undefined {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
return normalizePublicTheme(params.get("theme"));
|
||||
}
|
||||
@@ -93,7 +86,7 @@ function readThemeFromSearch(search: string): ThemeType | undefined {
|
||||
* 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先),
|
||||
* postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。
|
||||
*/
|
||||
export function usePublicEmbedTheme(): ThemeType | undefined {
|
||||
export function usePublicEmbedTheme(): "dark" | "light" | undefined {
|
||||
const location = useLocation();
|
||||
|
||||
const fromUrl = useMemo(() => {
|
||||
@@ -104,7 +97,7 @@ export function usePublicEmbedTheme(): ThemeType | undefined {
|
||||
return inner ?? outer;
|
||||
}, [location.search]);
|
||||
|
||||
const [override, setOverride] = useState<ThemeType | undefined>(undefined);
|
||||
const [override, setOverride] = useState<"dark" | "light" | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
|
||||
export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
|
||||
/**
|
||||
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
|
||||
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
|
||||
@@ -27,11 +31,9 @@
|
||||
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
|
||||
*/
|
||||
|
||||
import type { ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
const EMBED_SESSION_KEY = "embed.session";
|
||||
|
||||
export type EmbedTheme = ThemeType;
|
||||
export type EmbedTheme = "light" | "dark";
|
||||
|
||||
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
|
||||
export interface EmbedSession {
|
||||
@@ -41,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` 只想要
|
||||
@@ -77,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");
|
||||
@@ -87,18 +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";
|
||||
if (v === "blue" || v === "lightblue" || v === "light-blue" || v === "浅蓝") return "lightblue";
|
||||
if (v === "green" || v === "lightgreen" || v === "light-green" || v === "绿色") return "lightgreen";
|
||||
return "light";
|
||||
}
|
||||
|
||||
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
|
||||
export function readEmbedThemeParam(search: string): string {
|
||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||
@@ -112,11 +109,9 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
const p = JSON.parse(raw) as Partial<EmbedSession>;
|
||||
return {
|
||||
embed: p.embed === true,
|
||||
theme:
|
||||
p.theme === "dark" || p.theme === "lightblue" || p.theme === "lightgreen"
|
||||
? p.theme
|
||||
: "light",
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
};
|
||||
@@ -128,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;
|
||||
}
|
||||
@@ -148,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」之并——任一置位都隐藏。
|
||||
@@ -158,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`,
|
||||
@@ -175,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);
|
||||
@@ -190,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;
|
||||
@@ -241,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 });
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { resolveThemeFromQuery } from "@/core/auth/external-login";
|
||||
import type { ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
@@ -42,7 +41,7 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams {
|
||||
return { sessionId, message, username, theme };
|
||||
}
|
||||
|
||||
export function resolveEmbedTheme(themeParam: string): ThemeType {
|
||||
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
|
||||
return resolveThemeFromQuery(themeParam || null);
|
||||
}
|
||||
|
||||
@@ -51,10 +50,8 @@ export function resolveEmbedTheme(themeParam: string): ThemeType {
|
||||
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
|
||||
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
|
||||
*/
|
||||
export function resolveEmbedRoundtableTheme(raw: string): ThemeType {
|
||||
export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "light" || v === "red" || v === "红白") return "light";
|
||||
if (v === "blue" || v === "lightblue" || v === "light-blue" || v === "浅蓝") return "lightblue";
|
||||
if (v === "green" || v === "lightgreen" || v === "light-green" || v === "绿色") return "lightgreen";
|
||||
return "dark";
|
||||
}
|
||||
@@ -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,13 +448,17 @@ export const enUS: Translations = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
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.",
|
||||
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
||||
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,13 +383,17 @@ export interface Translations {
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
lightyellow: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
lightyellowDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
|
||||
@@ -429,13 +429,17 @@ export const zhCN: Translations = {
|
||||
system: "系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
lightyellow: "浅黄色",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
|
||||
@@ -2,8 +2,13 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildChatsIframeRedirectPath,
|
||||
buildChatsIframeThreadPath,
|
||||
isExplicitBrainyIframeMode,
|
||||
isChatsIframeWorkspacePath,
|
||||
resolvePostLoginRedirectPath,
|
||||
resolveForcedThemeForPath,
|
||||
resolveStandardChatFallback,
|
||||
} from "./chats-iframe-route.ts";
|
||||
|
||||
test("matches only workspace chats_iframe routes", () => {
|
||||
@@ -31,3 +36,164 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
);
|
||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||
});
|
||||
|
||||
test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
|
||||
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("upgrades legacy iframe-source routes to the iframe protocol", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&hideChrome=1",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&hideChrome=1&chatMode=iframe",
|
||||
);
|
||||
});
|
||||
|
||||
test("honors an explicit chat mode over the legacy iframe source", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&chatMode=full",
|
||||
),
|
||||
"/page/workspace/chats/new?chatMode=full",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats a blank chat mode as absent for the legacy iframe source", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&chatMode=%20%20",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the iframe theme in the post-login redirect", () => {
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath("?from=iframe"),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
|
||||
);
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath(
|
||||
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the iframe redirect ahead of login-method fallbacks", () => {
|
||||
assert.equal(
|
||||
resolvePostLoginRedirectPath(
|
||||
"?from=iframe&theme=dark-blue&yUserId=user-1",
|
||||
"/page/workspace/chats/new",
|
||||
),
|
||||
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
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&chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
|
||||
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,141 @@
|
||||
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
||||
|
||||
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
}
|
||||
|
||||
export function isChatsIframeLogin(search: string): boolean {
|
||||
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
target.set("chatMode", requestedMode);
|
||||
} else if (isChatsIframeLogin(search)) {
|
||||
// Plugin links historically identify this protocol with `from=iframe`.
|
||||
// Normalize that legacy marker into the explicit mode before handing the
|
||||
// URL to the workspace route, so it does not fall back to the full chat.
|
||||
target.set("chatMode", "iframe");
|
||||
}
|
||||
|
||||
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 {
|
||||
return isChatsIframeLogin(search)
|
||||
? buildChatsIframeRedirectPath(search)
|
||||
: fallbackPath;
|
||||
}
|
||||
|
||||
export function isChatsIframeWorkspacePath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/page/workspace/chats_iframe/new" ||
|
||||
pathname === CHATS_IFRAME_PATH ||
|
||||
pathname.startsWith("/page/workspace/chats_iframe/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lightweight iframe page is a private transport for the Brainy
|
||||
* postMessage protocol, not a general-purpose embedded chat page.
|
||||
*
|
||||
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary
|
||||
* iframes and micro-frontend containers from accidentally landing on the
|
||||
* protocol-only page.
|
||||
*/
|
||||
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) || isExplicitBrainyIframeMode(search)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||
const params = new URLSearchParams(normalizedSearch);
|
||||
const requestedMode = params.get("chatMode")?.trim();
|
||||
if (isChatsIframeLogin(search) && !requestedMode) {
|
||||
params.set("chatMode", "iframe");
|
||||
const targetSearch = params.toString();
|
||||
return targetSearch ? `${pathname}?${targetSearch}` : pathname;
|
||||
}
|
||||
|
||||
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";
|
||||
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 && isChatsIframeLogin(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: "大屏绘制",
|
||||
|
||||
@@ -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,40 @@ export async function updatePublishSquares(
|
||||
return data;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen";
|
||||
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;
|
||||
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 +337,7 @@ export interface AppearanceSettings {
|
||||
default_agent_view: ViewMode;
|
||||
default_layout_mode: LayoutMode;
|
||||
compact_shortcut_buttons: CompactShortcutButton[];
|
||||
custom_themes: CustomThemePreset[];
|
||||
}
|
||||
|
||||
export interface AppearanceUpdate {
|
||||
@@ -312,6 +346,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 +504,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,284 @@
|
||||
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",
|
||||
"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;
|
||||
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 sidebarHex = toHex(sidebar);
|
||||
|
||||
return {
|
||||
"--workspace-brand": primaryHex,
|
||||
"--workspace-brand-foreground": primaryForeground,
|
||||
"--brand-200": accentHex,
|
||||
"--brand-900": actionHex,
|
||||
"--brand-950": actionActiveHex,
|
||||
"--theme-button-hover": primaryHoverHex,
|
||||
"--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,165 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Loader2, Save, Settings2, X } from "lucide-react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
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();
|
||||
const isAdmin = auth?.system_role === "admin";
|
||||
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 loadConfiguration = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [mappings, availableChains] = await Promise.all([
|
||||
listBusinessMappings(),
|
||||
listChains(isAdmin ? "all" : "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);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfiguration();
|
||||
}, [loadConfiguration]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
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);
|
||||
}
|
||||
}, [chains, selectedChainId]);
|
||||
|
||||
const toolbarSlot = (
|
||||
<>
|
||||
<div className="eightbf-roundtable__chain-status" aria-live="polite">
|
||||
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
|
||||
<span className="eightbf-roundtable__chain-status-value">
|
||||
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
|
||||
</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__chain-settings-button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
配置业务链
|
||||
</button>
|
||||
)}
|
||||
{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">{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>
|
||||
)}
|
||||
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundtablePlanningPage
|
||||
compactChrome={compactChrome}
|
||||
lockedChainId={mapping?.chainId ?? null}
|
||||
lockBusinessChain
|
||||
storageTaskId={storageTaskId}
|
||||
toolbarSlot={toolbarSlot}
|
||||
rootClassName="eightbf-roundtable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* 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); }
|
||||
|
||||
/* 可在此继续覆盖第 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,34 @@
|
||||
/* 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; }
|
||||
|
||||
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
|
||||
@@ -31,8 +31,7 @@ export const env = {
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
/**
|
||||
* Deployment-level compact navigation mode. This is intentionally a runtime
|
||||
* flag so an operations deployment can switch the shell without rebuilding
|
||||
* the frontend bundle.
|
||||
* 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";
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<WorkspaceContainer className="bg-white dark:bg-slate-950">
|
||||
<WorkspaceBody>
|
||||
<AgentGallery />
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||
|
||||
export default function AgentsPage() {
|
||||
return <AgentGallery />;
|
||||
}
|
||||
@@ -35,7 +35,9 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
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";
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
@@ -98,9 +101,16 @@ 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();
|
||||
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
|
||||
const [effectiveLayoutMode] = useLayoutMode();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -440,9 +450,22 @@ export default function ChatPage() {
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。
|
||||
const compactChrome = !isChatsIframeWorkspacePath(pathname);
|
||||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||||
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
|
||||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
|
||||
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
|
||||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||||
const layoutMode = effectiveLayoutMode;
|
||||
const chromeByPreference =
|
||||
fullChatMode || layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: readSidebarEnabled();
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
const compactActionsHidden = isHideChromeActive();
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
@@ -454,16 +477,20 @@ export default function ChatPage() {
|
||||
compactChrome && "workspace-chat-bg",
|
||||
)}
|
||||
>
|
||||
{(!isLandingState || compactChrome) && (
|
||||
<header className="bg-background border-border/80 absolute top-0 right-0 left-0 z-30 flex h-14 shrink-0 items-center border-b px-5">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
{!isLandingState && (
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
)}
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
{compactChrome && !historySidebarOpen && (
|
||||
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -487,20 +514,7 @@ export default function ChatPage() {
|
||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{compactChrome ? (
|
||||
<>
|
||||
{!env.FORCE_COLLAPSED_SIDEBAR && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<div
|
||||
className="bg-border/70 mx-1 h-6 w-px shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UserIdentityBadge variant="topbar" />
|
||||
</>
|
||||
) : (
|
||||
{!compactChrome && !compactActionsHidden && (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
@@ -514,7 +528,6 @@ export default function ChatPage() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
{/*
|
||||
@@ -550,7 +563,7 @@ export default function ChatPage() {
|
||||
"pointer-events-none absolute z-30 flex justify-center px-4",
|
||||
// 紧凑落地态:整块垂直+水平居中于对话区
|
||||
compactLanding
|
||||
? "inset-0 items-start overflow-y-auto pt-[clamp(82px,16vh,146px)] pb-12"
|
||||
? "inset-0 items-center"
|
||||
: "right-0 bottom-0 left-0",
|
||||
)}
|
||||
>
|
||||
@@ -562,7 +575,7 @@ export default function ChatPage() {
|
||||
!compactLanding &&
|
||||
"-translate-y-[calc(50vh-96px)]",
|
||||
compactLanding
|
||||
? "max-w-[1024px]"
|
||||
? "max-w-(--container-width-xl)"
|
||||
: isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
@@ -581,7 +594,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
{isLandingState && mounted &&
|
||||
(compactLanding ? (
|
||||
<CompactWelcome className="mb-14" />
|
||||
<CompactWelcome className="mb-6 -translate-y-4" />
|
||||
) : (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
@@ -589,7 +602,7 @@ export default function ChatPage() {
|
||||
/>
|
||||
))}
|
||||
{mounted ? (
|
||||
<div className={cn("w-full", !compactLanding && "-translate-y-4")}>
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ImportConversationsDialog } from "@/components/workspace/thread-share-d
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
@@ -184,6 +185,7 @@ export default function ChatsPage({
|
||||
compactChrome ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BotIcon,
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
@@ -25,12 +27,13 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||
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,
|
||||
@@ -42,6 +45,7 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type BrainyChatSubmitMessage,
|
||||
@@ -126,6 +130,8 @@ function postBrainyAuthToken(accessToken: string) {
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -141,6 +147,34 @@ export default function IframeChatPage() {
|
||||
useState<PendingIframeSubmit | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
// The protocol page must still be escapable when no curated selector list
|
||||
// has been configured. Include the user's own and public agents in this
|
||||
// page's native fallback picker. A native select also remains interactive
|
||||
// in iframe/micro-frontend environments where a portal dialog can fail.
|
||||
const { agents: mineAgents } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const { agents: squareAgents } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const fallbackAgents = useMemo(() => {
|
||||
const uniqueAgents = new Map<
|
||||
string,
|
||||
(typeof selectorAgents)[number]
|
||||
>();
|
||||
for (const agent of [
|
||||
...selectorAgents,
|
||||
...mineAgents,
|
||||
...squareAgents,
|
||||
]) {
|
||||
uniqueAgents.set(agent.id, agent);
|
||||
}
|
||||
return [...uniqueAgents.values()];
|
||||
}, [mineAgents, selectorAgents, squareAgents]);
|
||||
const { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
@@ -230,7 +264,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) {
|
||||
@@ -345,6 +383,30 @@ export default function IframeChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Manual fallback for accidental protocol-page visits.
|
||||
*
|
||||
* A real Brainy integration drives this page with postMessage. A human who
|
||||
* reaches it directly should not be trapped here: move them to the standard
|
||||
* chat workspace, optionally opening the selected agent.
|
||||
*/
|
||||
const enterStandardChat = useCallback(
|
||||
(agentId?: string) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("from");
|
||||
params.set("chatMode", "full");
|
||||
params.delete("agent");
|
||||
if (agentId) {
|
||||
params.set("agent", agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}
|
||||
navigate(`${routeBase}/chats/new?${params.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[location.search, navigate, routeBase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
@@ -643,15 +705,46 @@ export default function IframeChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
<div className="mt-4">
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
选择智能体进入问答
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
|
||||
<BotIcon className="mr-2 size-3.5 shrink-0" />
|
||||
<select
|
||||
aria-label="选择智能体并进入问答"
|
||||
defaultValue=""
|
||||
disabled={isUploading || selectorLoading}
|
||||
onChange={(event) => {
|
||||
const agentId = event.target.value;
|
||||
if (agentId) enterStandardChat(agentId);
|
||||
}}
|
||||
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
|
||||
</option>
|
||||
{fallbackAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{desensitize(agent.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-3 text-[10px]"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{!selectorLoading && fallbackAgents.length === 0 && (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
暂无可选智能体
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function LightAppIframeView() {
|
||||
|
||||
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
|
||||
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
|
||||
// 深色=dark-blue,绿色=green,浅蓝=blue,其余(红白)=red。
|
||||
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。
|
||||
const themeColor: AppThemeColor =
|
||||
theme === "dark"
|
||||
? "dark-blue"
|
||||
@@ -27,7 +27,11 @@ export default function LightAppIframeView() {
|
||||
? "green"
|
||||
: theme === "lightblue"
|
||||
? "blue"
|
||||
: "red";
|
||||
: theme === "iceblue"
|
||||
? "iceblue"
|
||||
: theme === "lightyellow"
|
||||
? "lightyellow"
|
||||
: "red";
|
||||
const src = useMemo(
|
||||
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
|
||||
[app, themeColor],
|
||||
|
||||
@@ -35,6 +35,7 @@ import { toast } from "sonner";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
@@ -65,6 +66,17 @@ function hashIndex(str: string, mod: number): number {
|
||||
}
|
||||
|
||||
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]",
|
||||
@@ -87,6 +99,28 @@ function getLightAccent(theme: ThemeType) {
|
||||
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]",
|
||||
@@ -290,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]", lightAccent.hoverText)}>
|
||||
<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>
|
||||
|
||||
@@ -346,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]", lightAccent.button)}
|
||||
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" />
|
||||
|
||||
@@ -9,8 +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 = () => {
|
||||
@@ -31,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%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -43,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" />
|
||||
@@ -66,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" />
|
||||
@@ -115,6 +124,8 @@ export default function LightAppNoteQAPage() {
|
||||
? "bg-sky-500 text-white hover:bg-sky-400"
|
||||
: 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,11 @@ 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,
|
||||
ROUTE_KEY_PARAM,
|
||||
} from '@/core/auth/route-key-login';
|
||||
import {
|
||||
persistExternalLoginParams,
|
||||
readExternalLoginParams,
|
||||
@@ -27,6 +32,11 @@ import {
|
||||
persistAccessToken,
|
||||
persistToolsToken,
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
buildChatsIframeRedirectPath,
|
||||
isChatsIframeLogin,
|
||||
resolvePostLoginRedirectPath,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
@@ -66,9 +76,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 +99,51 @@ export default function LoginPage() {
|
||||
() => readEmbedParam(searchParams.toString()) === 'on',
|
||||
[searchParams],
|
||||
);
|
||||
const redirectPath = useMemo(
|
||||
const requestedChatMode =
|
||||
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
|
||||
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
|
||||
// Wujie is an application container, not the Brainy iframe protocol. It
|
||||
// must therefore land on the regular full ChatPage. Plugin links with
|
||||
// `from=iframe` are normalized into the explicit iframe protocol mode.
|
||||
const fullChatRequested = useMemo(
|
||||
() =>
|
||||
searchParams.get('from') === IFRAME_LOGIN_SOURCE
|
||||
? IFRAME_REDIRECT_PATH
|
||||
: DEFAULT_REDIRECT_PATH,
|
||||
[searchParams],
|
||||
!brainyIframeRequested &&
|
||||
(isWujieEnv() ||
|
||||
requestedChatMode === FULL_CHAT_MODE),
|
||||
[brainyIframeRequested, requestedChatMode, searchParams],
|
||||
);
|
||||
const isIframeLogin = useMemo(
|
||||
() =>
|
||||
brainyIframeRequested ||
|
||||
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
|
||||
[brainyIframeRequested, fullChatRequested, searchParams],
|
||||
);
|
||||
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(
|
||||
() => {
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
// 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 buildChatsIframeRedirectPath(searchParams.toString());
|
||||
}
|
||||
if (fullChatRequested) {
|
||||
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
|
||||
}
|
||||
return resolvePostLoginRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
},
|
||||
[brainyIframeRequested, fullChatRequested, searchParams, yUserId],
|
||||
);
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
const access_token = searchParams.get('access_token')?.trim() ?? '';
|
||||
@@ -109,6 +151,14 @@ 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 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 +173,29 @@ 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);
|
||||
// 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 +204,7 @@ export default function LoginPage() {
|
||||
});
|
||||
const qs = target.toString();
|
||||
return qs ? `${pathOnly}?${qs}` : pathOnly;
|
||||
}, [searchParams]);
|
||||
}, [fullChatRequested, routeKeyTarget, searchParams]);
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
@@ -176,14 +239,14 @@ 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;
|
||||
if (isEmbed || isIframeLogin) return;
|
||||
const themeValue = wujieProps.theme || externalParams.theme;
|
||||
if (!themeValue) return;
|
||||
setTheme(resolveThemeFromQuery(themeValue));
|
||||
}, [isEmbed, wujieProps.theme, externalParams.theme, setTheme]);
|
||||
}, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]);
|
||||
|
||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||
useEffect(() => {
|
||||
@@ -220,7 +283,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 +330,7 @@ export default function LoginPage() {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||||
auth = await loginByUsername(deerflowUsername, effectiveGatePassword);
|
||||
loginUserName = deerflowUsername;
|
||||
}
|
||||
if (cancelled) {
|
||||
@@ -341,7 +406,7 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
router.replace(redirectPath);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -367,10 +432,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
|
||||
|
||||