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
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
name: deerflow-ui-guard
|
|
||||||
description: Improve or review UI in the DeerFlow Vite/React frontend while preserving its light, dark-blue, and light-blue themes, Radix/shadcn-style primitives, Chinese enterprise density, embedded routes, and Chrome 109 compatibility. Use for visual refinements, new frontend pages/components, UI reviews, token cleanup, or design-system work under frontend-web/.
|
|
||||||
---
|
|
||||||
|
|
||||||
# DeerFlow UI Guard
|
|
||||||
|
|
||||||
Use this skill only in this repository. Preserve the product's existing visual system; do not replace it with a generic SaaS template or a new component library.
|
|
||||||
|
|
||||||
## Read the relevant sources first
|
|
||||||
|
|
||||||
1. Read `references/project-sources.md`, then the relevant theme specification.
|
|
||||||
2. Inspect the page and the nearest existing components before proposing or changing UI.
|
|
||||||
3. Read `frontend-web/src/styles/globals.css` and the affected primitives under `frontend-web/src/components/ui/` before changing tokens, shared styles, or variants.
|
|
||||||
|
|
||||||
## Design and implementation rules
|
|
||||||
|
|
||||||
- Reuse the existing Radix/shadcn-style primitives, `lucide-react`, Tailwind v4, and established CSS variables. Do not add Ant Design, another component system, or a visual-effects package unless the user explicitly asks.
|
|
||||||
- Keep all three themes correct: light/red, dark/blue, and light-blue. Prefer semantic classes and existing variables over raw color values. When a new token is necessary, define matching values for every theme.
|
|
||||||
- Preserve Chinese enterprise-product density and task completion speed. Make hierarchy, primary action, status, empty states, error recovery, and keyboard focus clearer before adding decoration.
|
|
||||||
- Keep the shell, deep links, authentication, embed/isolation behavior, sidebar menu semantics, and responsive layout unchanged unless the request explicitly targets them.
|
|
||||||
- Avoid unscoped global overrides and `!important`; place page-specific styling beside the feature and make selectors local.
|
|
||||||
- Support the Chrome 109 constraint. Do not introduce unguarded `oklch()`, `oklab()`, `lab()`, `lch()`, or `color-mix()` values.
|
|
||||||
- Preserve accessible names, keyboard interaction, visible `:focus-visible` states, sufficient contrast, and reduced-motion behavior for new or changed controls.
|
|
||||||
|
|
||||||
## Delivery workflow
|
|
||||||
|
|
||||||
1. State which existing theme and UI primitives the work will reuse.
|
|
||||||
2. Make the smallest coherent change; do not perform an unrelated visual sweep.
|
|
||||||
3. Validate with `pnpm typecheck`; when CSS or built UI changes, also run `pnpm build` and `pnpm check:chrome109-css` from `frontend-web/` when practical.
|
|
||||||
4. If a browser is available, inspect screenshots in light, dark, and light-blue modes. Otherwise say that visual verification remains outstanding.
|
|
||||||
5. For a review-only request, use `web-design-guidelines` as an additional quality gate when it is available, then distinguish confirmed issues from visual checks that still need a browser.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "DeerFlow UI Guard"
|
|
||||||
short_description: "Apply DeerFlow themes and UI safety checks."
|
|
||||||
default_prompt: "Use $deerflow-ui-guard to improve this DeerFlow UI without breaking its themes or legacy-browser support."
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# DeerFlow UI sources
|
|
||||||
|
|
||||||
Read only the files relevant to the requested surface.
|
|
||||||
|
|
||||||
## Canonical themes
|
|
||||||
|
|
||||||
- Light/red: `frontend-web/docs/浅红色UI规范/README.md` and its focused section files.
|
|
||||||
- Dark/blue: `frontend-web/docs/ui规范-深蓝/README.md` and its focused section files.
|
|
||||||
- Light-blue: `frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md`.
|
|
||||||
- Runtime tokens and browser fallbacks: `frontend-web/src/styles/globals.css`.
|
|
||||||
|
|
||||||
## Shared implementation
|
|
||||||
|
|
||||||
- UI primitives: `frontend-web/src/components/ui/`.
|
|
||||||
- Theme selection: `frontend-web/src/contexts/ThemeContext.tsx` and `frontend-web/src/App.tsx`.
|
|
||||||
- Application shell and embed behavior: `frontend-web/src/pages/PageRoutes.tsx`.
|
|
||||||
- Dynamic navigation: `frontend-web/src/core/page-layout/use-sidebar-menu.ts`.
|
|
||||||
|
|
||||||
## Required constraints
|
|
||||||
|
|
||||||
- Use `pnpm` in `frontend-web/`.
|
|
||||||
- Retain Chrome 109 compatibility; validate CSS with `pnpm check:chrome109-css` after a build.
|
|
||||||
- Do not move or replace public assets, route behavior, or authentication as part of a visual-only change.
|
|
||||||
@@ -78,12 +78,6 @@ offline-backend-20260512/backend/.deer-flow/*
|
|||||||
# Per-user skill_evolution overrides — runtime data written by the API
|
# Per-user skill_evolution overrides — runtime data written by the API
|
||||||
offline-backend-20260512/backend/skill_evolution.json
|
offline-backend-20260512/backend/skill_evolution.json
|
||||||
skills/
|
skills/
|
||||||
# Keep the project-local Codex UI guard under version control. The broad
|
|
||||||
# skills/ rule above still excludes runtime skill directories elsewhere.
|
|
||||||
!.codex/
|
|
||||||
!.codex/skills/
|
|
||||||
!.codex/skills/deerflow-ui-guard/
|
|
||||||
!.codex/skills/deerflow-ui-guard/**
|
|
||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
@@ -99,6 +93,7 @@ git-clone/
|
|||||||
# Frontend offline pack working directories
|
# Frontend offline pack working directories
|
||||||
frontend-web/.offline-pack-build/
|
frontend-web/.offline-pack-build/
|
||||||
frontend-web/.offline-extract-test/
|
frontend-web/.offline-extract-test/
|
||||||
|
frontend-web-node_modules-offline-*.tar.gz
|
||||||
|
|
||||||
.codegraph
|
.codegraph
|
||||||
codegraph.mdc
|
codegraph.mdc
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# Design QA — skills card list
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Only the skill-card list presentation was changed. The page header, summary cards, filters, top actions, sidebar, and existing interactions were left intact.
|
|
||||||
|
|
||||||
## Visual evidence
|
|
||||||
|
|
||||||
- Source visual truth: `C:\Users\admin\AppData\Local\Temp\codex-clipboard-8ca03817-ebd4-43e1-9d7c-2119e87d331c.png`
|
|
||||||
- Implementation URL: `http://localhost:5173/#/page/workspace/skills`
|
|
||||||
- Implementation capture: `F:\react01\deeflow-code\deerflow-server\skills-card-list-implementation.png`
|
|
||||||
- Focused source/implementation comparison: `F:\react01\deeflow-code\deerflow-server\skills-card-list-comparison.png`
|
|
||||||
- Viewport and state: Edge, desktop light theme, default list mode. The supplied reference contains three sample skills; the live implementation currently exposes one skill, so the comparison focuses on the first card row rather than fabricated data.
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
No actionable P0, P1, or P2 visual issue remains within the requested list-card scope.
|
|
||||||
|
|
||||||
- **Hierarchy and layout:** the card now begins with a large semantic skill icon, then groups name, existing status badges, description, and publisher before the existing tag, use-count, state, activity, and action areas. The five-column grid keeps the important skill identity visible while preserving the original live fields.
|
|
||||||
- **Typography and spacing:** the title, description, metadata, and tags use the existing product type scale with a compact 12px card radius and consistent horizontal rhythm. The row remains responsive and collapses cleanly on narrower widths.
|
|
||||||
- **Color and surfaces:** category icons use restrained solid blue/green/violet/amber surfaces, tags remain subdued, and enabled status uses a quiet green pill. Borders and hover surfaces follow the existing neutral card treatment; no gradients were introduced.
|
|
||||||
- **Image and icon fidelity:** the source uses symbolic skill artwork. The implementation uses the closest existing Lucide workflow, globe, file, and upload icons in colored circular surfaces rather than adding screenshot-derived artwork or a new custom asset.
|
|
||||||
- **Feature fidelity:** all pre-existing action controls, view modes, and list metadata stay visible. The reference's toggle was not copied as a fake interactive control because this page had no enable/disable mutation; its existing `enabled` data is shown as a read-only status pill instead.
|
|
||||||
|
|
||||||
## Comparison history
|
|
||||||
|
|
||||||
1. **[P1, fixed] The original live skill row had little identity or information hierarchy.**
|
|
||||||
- Earlier state: title, description, metadata, and the action cluster were visually detached, with no category icon or clear scanning order.
|
|
||||||
- Fix: rebuilt only the list/card row layout around existing data and handlers, added semantic colored icon surfaces, compact state/use/activity columns, and a unified action cluster.
|
|
||||||
- Evidence: `skills-card-list-comparison.png` contains the reference card row and the live implementation card row in one focused comparison.
|
|
||||||
|
|
||||||
## Interaction and engineering checks
|
|
||||||
|
|
||||||
- Edge interaction check: passed. The live row retains 7 existing action buttons; opening the details action displays its dialog.
|
|
||||||
- View-mode behavior: passed. The existing list/card view control still switches to card mode (`aria-pressed=true`); the card view uses the same restyled visual language.
|
|
||||||
- Browser runtime: two guest-bootstrap resource requests returned `401 Unauthorized` after reload. They are unrelated to the skill-card styling and did not prevent the list, details dialog, or view-mode control from working.
|
|
||||||
- `pnpm build`: passed. The project still emits the pre-existing `/images/deer.svg` runtime-resolution warning.
|
|
||||||
- `git diff --check`: passed; only existing line-ending notices were emitted for unrelated files.
|
|
||||||
|
|
||||||
## Implementation checklist
|
|
||||||
|
|
||||||
- [x] Skill-card list restyled without changing page chrome or filtering controls
|
|
||||||
- [x] Existing skill actions, metadata, and view modes retained
|
|
||||||
- [x] Live `enabled` data displayed without inventing a new mutation control
|
|
||||||
- [x] Source and implementation reviewed in a single focused comparison image
|
|
||||||
- [x] Edge interaction and production build verified
|
|
||||||
|
|
||||||
final result: passed
|
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
|
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
|
||||||
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
|
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
|
||||||
# VITE_MOCK_COP_TASK_DETAIL=false
|
# VITE_MOCK_COP_TASK_DETAIL=false
|
||||||
|
# VITE_POSITION_ROUNDTABLE_DEMO_MODE=false
|
||||||
|
# true=免登录直达岗位分析,完全使用前端 JSON,不请求任何后台接口。
|
||||||
|
# 仅限离线演示环境;生产环境请保持 false。
|
||||||
|
# VITE_POSITION_ROUNDTABLE_STANDALONE_MODE=false
|
||||||
|
|
||||||
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
||||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||||
@@ -50,4 +54,6 @@
|
|||||||
# 可选
|
# 可选
|
||||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||||
# VITE_STATIC_WEBSITE_ONLY=
|
# VITE_STATIC_WEBSITE_ONLY=
|
||||||
|
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
|
||||||
|
# VITE_FORCE_COLLAPSED_SIDEBAR=false
|
||||||
# VITE_GITHUB_OAUTH_TOKEN=
|
# VITE_GITHUB_OAUTH_TOKEN=
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
**Comparison target**
|
||||||
|
|
||||||
|
- Source visual truth (dark intelligent-agent list): `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-75f6ce1c-cd70-4b8c-a80a-a3d100105c69.png`
|
||||||
|
- Additional source references: `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-00567484-ba8a-4595-b386-52e1467ae9cf.png` (segmented switch), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-02808803-05ba-4f2c-be50-c72b484a9f06.png` (business-chain node card), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-adf38fec-7baf-4d01-9c73-bdbae94b774e.png` (business-chain selector)
|
||||||
|
- Implementation screenshots: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-light.png`, `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-dark.png`
|
||||||
|
- Full-view comparison evidence: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-comparison.png`
|
||||||
|
- Viewport: 1280 × 720. Light route: `#/page/strategy/qa/position-roundtable`; dark embedded route: `#/page/strategy/qa/position-roundtable?embed=1&theme=dark`.
|
||||||
|
- State: local browser has no authenticated API session. The original intent API is invoked and reports `Authentication required`; business-chain and agent endpoints return no usable data.
|
||||||
|
|
||||||
|
**Findings**
|
||||||
|
|
||||||
|
- [P1] Populated agent and business-chain states cannot be compared yet.
|
||||||
|
Location: right agent list and non-planning-role chain diagram.
|
||||||
|
Evidence: reference images show populated cards and nodes; the implementation has `0 个` agents and no available business chain in the unauthenticated preview.
|
||||||
|
Impact: card density, selected-card indicator, chain node flow, and agent-detail transition cannot be truthfully verified against live data.
|
||||||
|
Fix: verify the route in an authenticated session with at least one public chain and tagged agents.
|
||||||
|
|
||||||
|
**Open Questions**
|
||||||
|
|
||||||
|
- The final business-chain stage arrangement is still pending product confirmation. The compact graph maps whatever stage configuration is returned, while intentionally omitting dependency-label text as requested.
|
||||||
|
|
||||||
|
**Implementation Checklist**
|
||||||
|
|
||||||
|
1. Left task panel is now display-only and retains its independent position-roundtable task adapter.
|
||||||
|
2. Business-chain selection uses the original roundtable card/radio/relationship-entry pattern.
|
||||||
|
3. Every position uses the original `useStep1Intent` interface and `Step1Panel` message list/input panel; only the position header changes.
|
||||||
|
4. The right switch now uses the exact `课题研究 / ai写作` segmented-control treatment (light gray track, white active state; dark blue glass equivalent).
|
||||||
|
5. The embedded layout widens the left panel to 360px and applies the deep-blue right-panel tokens used by the reference.
|
||||||
|
6. Other roles render a taller top-to-bottom card-node flow without dependency labels. The canvas supports mouse-drag panning, and each node retains only a name plus a single-line description.
|
||||||
|
|
||||||
|
**Follow-up Polish**
|
||||||
|
|
||||||
|
- [P3] If live content is unusually long, tune the compact flow node width or horizontal scrolling after seeing the production chain data.
|
||||||
|
|
||||||
|
**Comparison history**
|
||||||
|
|
||||||
|
- Iteration 1: task fields were editable and the right TDesign card tabs looked unlike the reference. Replaced the task form with display-only information and replaced the tabs with the existing `课题研究 / ai写作` segmented switch.
|
||||||
|
- Iteration 2: business-chain selection was a dropdown and the flow was vertical avatar chips. Replaced it with original-roundtable selection cards and compact business-chain configuration-style node cards with link lines but no dependency labels.
|
||||||
|
- Iteration 3: dark right-panel background differed from the supplied deep-blue reference. Applied the `#032650` / `#042b59` / `#2b8ffe` token family and captured the revised embedded dark state in the full-view comparison.
|
||||||
|
- Iteration 4: removed the custom disabled input from non-planning positions and reused the original roundtable input panel. Enlarged the left flow viewport, retained its connection lines, and removed node level/seat/status metadata.
|
||||||
|
- Iteration 5: converted the compact flow from left-to-right to top-to-bottom. Added mouse-drag panning and single-line description truncation; populated-chain visual QA remains blocked by the unauthenticated preview.
|
||||||
|
- Iteration 6: constrained the flow canvas to its card viewport, narrowed nodes from 190px to 158px, and realigned branch gaps plus connector anchors to prevent clipping and offset lines.
|
||||||
|
|
||||||
|
**Final result**
|
||||||
|
|
||||||
|
blocked
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# 岗位会商交付闭环与内置智能体实现计划
|
||||||
|
|
||||||
|
> 页面:`/page/strategy/qa/position-roundtable`
|
||||||
|
>
|
||||||
|
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
|
||||||
|
|
||||||
|
## 实施核对(2026-07-21)
|
||||||
|
|
||||||
|
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
|
||||||
|
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
|
||||||
|
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
|
||||||
|
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
|
||||||
|
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
|
||||||
|
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
|
||||||
|
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
|
||||||
|
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
|
||||||
|
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
|
||||||
|
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
|
||||||
|
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
|
||||||
|
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
|
||||||
|
|
||||||
|
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
|
||||||
|
|
||||||
|
## 数据持久化与灾难恢复(2026-07-23)
|
||||||
|
|
||||||
|
岗位会商现在使用“两层持久化”,两层职责不同:
|
||||||
|
|
||||||
|
1. LangGraph checkpoint/thread 是智能体继续推理与流式问答的执行源。
|
||||||
|
2. `position_roundtable_sessions` / `position_roundtable_nodes` 是页面历史的数据库恢复源。
|
||||||
|
|
||||||
|
数据库恢复源会保存:
|
||||||
|
|
||||||
|
- 情报分析岗完整消息、步骤时间线和未发送输入草稿;
|
||||||
|
- 每个业务链岗位的完整消息和未发送输入草稿;
|
||||||
|
- 方案总结、行动规划两个收口智能体的完整消息和未发送输入草稿;
|
||||||
|
- 已确认意图、冻结任务与业务链快照、节点状态、版本、驳回记录;
|
||||||
|
- Markdown / JSON 等文本产物正文(单文件最多 400 万字符)和文件元数据。
|
||||||
|
|
||||||
|
因此即使线上重启后 checkpoint 或沙箱 outputs 卷丢失,历史页仍能从数据库展示对话与产物正文,后续收口智能体也能继续读取数据库中的有效上游交付。新任务在意图识别开始前就会创建 Session,用户不必等到“启动业务链”才获得持久化保护。历史下拉支持删除,删除会显式清理 Session 与全部节点恢复快照,不依赖 SQLite 外键开关。
|
||||||
|
|
||||||
|
对应迁移:`20260723_05_position_roundtable_conversations.py`。部署时必须执行到 Alembic head。
|
||||||
|
|
||||||
|
## 0. 核心结论
|
||||||
|
|
||||||
|
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
|
||||||
|
- 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。
|
||||||
|
- 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。
|
||||||
|
- 行动规划智能体建议作为新的内置智能体新增。
|
||||||
|
- 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。
|
||||||
|
- 输出:行动规划报告、结构化子任务清单。
|
||||||
|
- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。
|
||||||
|
- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。
|
||||||
|
|
||||||
|
## 1. 目标业务流程
|
||||||
|
|
||||||
|
1. 情报分析岗进入页面,查看任务信息并完成意图识别。
|
||||||
|
2. 用户点击“确认意图并启动业务链”。
|
||||||
|
3. 启动前做校验:
|
||||||
|
- 未选择业务链:提示“请先选择业务链”。
|
||||||
|
- 业务链存在未配置岗位的席位:提示并引导去业务链配置页。
|
||||||
|
- 业务链席位缺少智能体:提示具体席位名称。
|
||||||
|
- 意图尚未完成:提示先完成任务意图识别。
|
||||||
|
4. 校验通过后冻结业务链快照,初始化岗位会商节点。
|
||||||
|
5. 各岗位只对分配给本岗位的智能体进行问答。
|
||||||
|
6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。
|
||||||
|
7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。
|
||||||
|
8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。
|
||||||
|
9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。
|
||||||
|
10. 方案总结完成后开放“行动规划”。
|
||||||
|
11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。
|
||||||
|
12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。
|
||||||
|
|
||||||
|
## 2. 状态设计
|
||||||
|
|
||||||
|
### 2.1 节点状态
|
||||||
|
|
||||||
|
现有节点状态需要扩展或映射为更贴近页面的展示状态:
|
||||||
|
|
||||||
|
| 展示状态 | 建议内部状态 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 |
|
||||||
|
| 进行中 | `running` | 当前智能体正在回答或产物正在生成 |
|
||||||
|
| 已完成 | `done` | 当前节点已有有效交付 |
|
||||||
|
| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 |
|
||||||
|
| 异常 | `error` | 生成或保存失败 |
|
||||||
|
|
||||||
|
建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。
|
||||||
|
|
||||||
|
### 2.2 产物状态
|
||||||
|
|
||||||
|
产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pending` | 还没有交付 |
|
||||||
|
| `running` | 正在生成 |
|
||||||
|
| `delivered` | 已交付且当前有效 |
|
||||||
|
| `rejected` | 被用户驳回 |
|
||||||
|
| `stale` | 受上游驳回或重做影响,已经失效 |
|
||||||
|
|
||||||
|
### 2.3 内置收口节点
|
||||||
|
|
||||||
|
在流程图中把两个内置节点追加到业务链末尾:
|
||||||
|
|
||||||
|
1. `builtin:summary`:方案总结智能体。
|
||||||
|
2. `builtin:action-plan`:行动规划智能体。
|
||||||
|
|
||||||
|
这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。
|
||||||
|
|
||||||
|
## 3. 数据模型调整
|
||||||
|
|
||||||
|
### 3.1 Session 扩展
|
||||||
|
|
||||||
|
`position_roundtable_sessions` 已使用下列持久化字段:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
|
||||||
|
| `summary_thread_id` | 方案总结智能体线程 |
|
||||||
|
| `summary_snapshot` | 最新有效总结报告快照 |
|
||||||
|
| `action_plan_thread_id` | 行动规划智能体线程 |
|
||||||
|
| `action_plan_snapshot` | 最新有效行动规划快照 |
|
||||||
|
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
|
||||||
|
|
||||||
|
### 3.2 Node 扩展
|
||||||
|
|
||||||
|
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
|
||||||
|
| `rejection_count` | 当前节点累计驳回次数 |
|
||||||
|
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
|
||||||
|
| `revision` | 当前有效交付版本 |
|
||||||
|
| `invalidated_by` | 导致当前节点失效的上游节点 key |
|
||||||
|
|
||||||
|
### 3.3 Deliverable 新表(后续可选)
|
||||||
|
|
||||||
|
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | 产物 ID |
|
||||||
|
| `session_id` | 会话 ID |
|
||||||
|
| `node_key` | 来源节点 |
|
||||||
|
| `agent_id` | 来源智能体 |
|
||||||
|
| `position_id` | 来源岗位 |
|
||||||
|
| `title` | 产物名称 |
|
||||||
|
| `kind` | `answer / file / summary / action_plan` |
|
||||||
|
| `content` | 文本产物内容摘要或正文 |
|
||||||
|
| `artifact_path` | 文件产物虚拟路径 |
|
||||||
|
| `thread_id` | 所属线程 |
|
||||||
|
| `status` | `pending / running / delivered / rejected / stale` |
|
||||||
|
| `revision` | 来源节点版本 |
|
||||||
|
| `rejection_title` | 驳回标题 |
|
||||||
|
| `rejection_reason` | 驳回理由 |
|
||||||
|
| `created_at` | 创建时间 |
|
||||||
|
| `updated_at` | 更新时间 |
|
||||||
|
|
||||||
|
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
|
||||||
|
|
||||||
|
## 4. 驳回与级联失效规则
|
||||||
|
|
||||||
|
### 4.1 基本驳回
|
||||||
|
|
||||||
|
用户点击产物卡片的“驳回”后弹窗填写:
|
||||||
|
|
||||||
|
- 驳回标题。
|
||||||
|
- 驳回理由。
|
||||||
|
|
||||||
|
提交后:
|
||||||
|
|
||||||
|
- 当前产物状态改为 `rejected`。
|
||||||
|
- 当前节点状态改为 `rejected` 或 `ready`。
|
||||||
|
- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。
|
||||||
|
- 记录 `last_rejection`,用于页面展示和后续追踪。
|
||||||
|
|
||||||
|
### 4.2 级联失效
|
||||||
|
|
||||||
|
如果驳回的节点位于第 N 阶段:
|
||||||
|
|
||||||
|
- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。
|
||||||
|
- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。
|
||||||
|
- 后续节点状态回到未完成展示态。
|
||||||
|
- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。
|
||||||
|
- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。
|
||||||
|
|
||||||
|
### 4.3 重新交付
|
||||||
|
|
||||||
|
被驳回节点重新问答并完成后:
|
||||||
|
|
||||||
|
- 当前节点产生新的 `revision`。
|
||||||
|
- 当前节点产物状态变为 `delivered`。
|
||||||
|
- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。
|
||||||
|
- 方案总结和行动规划必须基于最新有效产物重新生成。
|
||||||
|
|
||||||
|
## 5. 接口规划
|
||||||
|
|
||||||
|
### 5.1 启动与校验
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 |
|
||||||
|
|
||||||
|
第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。
|
||||||
|
|
||||||
|
### 5.2 产物
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
|
||||||
|
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||||
|
|
||||||
|
### 5.3 方案总结与行动规划
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
|
||||||
|
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
|
||||||
|
|
||||||
|
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
|
||||||
|
|
||||||
|
## 6. 前端实施步骤
|
||||||
|
|
||||||
|
### 阶段 1:状态与启动校验(已完成)
|
||||||
|
|
||||||
|
- 扩展 `PositionNodeStatus` 和状态显示映射。
|
||||||
|
- “确认意图并启动业务链”前补齐校验提示。
|
||||||
|
- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。
|
||||||
|
- 左侧流程图节点右上角添加状态小图标。
|
||||||
|
- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。
|
||||||
|
|
||||||
|
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
|
||||||
|
|
||||||
|
### 阶段 2:流程图弹窗(已完成)
|
||||||
|
|
||||||
|
- 左侧保留小型从上到下流程图。
|
||||||
|
- 流程图右上角增加“查看”按钮。
|
||||||
|
- 点击后打开弹窗,展示更大的流程图。
|
||||||
|
- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。
|
||||||
|
|
||||||
|
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
|
||||||
|
|
||||||
|
### 阶段 3:产物卡片与预览(已完成)
|
||||||
|
|
||||||
|
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
|
||||||
|
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
|
||||||
|
- 点击卡片打开详情弹窗。
|
||||||
|
- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。
|
||||||
|
- 每个有效产物提供“驳回”入口。
|
||||||
|
|
||||||
|
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
|
||||||
|
|
||||||
|
### 阶段 4:驳回弹窗与级联失效(已完成)
|
||||||
|
|
||||||
|
- 新增 `RejectDeliverableDialog`。
|
||||||
|
- 驳回表单包含标题和理由,均必填。
|
||||||
|
- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。
|
||||||
|
- 前端刷新 Session、Node、Deliverable 和流程图状态。
|
||||||
|
- 被驳回节点在对话区显示驳回信息,提示用户重新交付。
|
||||||
|
|
||||||
|
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
|
||||||
|
|
||||||
|
### 阶段 5:方案总结智能体(已完成)
|
||||||
|
|
||||||
|
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
|
||||||
|
- 输入材料改为岗位会商材料:
|
||||||
|
- 任务信息。
|
||||||
|
- 情报分析岗意图结果。
|
||||||
|
- 业务链快照。
|
||||||
|
- 所有状态为 `delivered` 的岗位产物。
|
||||||
|
- 已驳回或失效产物只作为历史参考,不进入默认总结材料。
|
||||||
|
- 方案总结节点追加到流程图末尾。
|
||||||
|
- 所有产物有效交付后才允许点击“生成方案总结”。
|
||||||
|
- 总结结果写入 `summary_snapshot`,同时进入产物列表。
|
||||||
|
|
||||||
|
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
|
||||||
|
|
||||||
|
### 阶段 6:行动规划智能体(已完成)
|
||||||
|
|
||||||
|
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
|
||||||
|
- 新增行动规划 prompt:
|
||||||
|
- 读取方案总结报告。
|
||||||
|
- 读取各岗位最终有效产物。
|
||||||
|
- 拆解为若干子任务。
|
||||||
|
- 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。
|
||||||
|
- 输出两类产物:
|
||||||
|
- `行动规划报告.md`。
|
||||||
|
- `action-plan-subtasks.json`。
|
||||||
|
- 行动规划节点追加到方案总结之后。
|
||||||
|
- 方案总结有效完成后才允许启动行动规划。
|
||||||
|
|
||||||
|
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
|
||||||
|
|
||||||
|
### 阶段 7:返回修改与重新生成(已完成)
|
||||||
|
|
||||||
|
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
|
||||||
|
- 驳回后自动显示需要重做的节点。
|
||||||
|
- 重做节点完成后,允许重新生成方案总结和行动规划。
|
||||||
|
- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。
|
||||||
|
|
||||||
|
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
|
||||||
|
|
||||||
|
### 阶段 8:测试与回归(已完成)
|
||||||
|
|
||||||
|
- 后端测试:
|
||||||
|
- activate 校验。
|
||||||
|
- 节点完成后产物入库。
|
||||||
|
- 驳回当前节点。
|
||||||
|
- 驳回第一阶段时后续阶段级联失效。
|
||||||
|
- 总结和行动规划失效。
|
||||||
|
- 前端测试:
|
||||||
|
- 启动校验提示。
|
||||||
|
- 状态图标显示。
|
||||||
|
- 产物卡片筛选和预览。
|
||||||
|
- 驳回弹窗必填校验。
|
||||||
|
- 回归验证:
|
||||||
|
- 原多智能体会商页面不受影响。
|
||||||
|
- 业务链普通配置入口不显示岗位配置。
|
||||||
|
- 岗位会商页面刷新恢复正常。
|
||||||
|
- 深色模式和嵌入模式布局不溢出。
|
||||||
|
|
||||||
|
## 7. 推荐提交拆分
|
||||||
|
|
||||||
|
1. `feat(position-roundtable): add delivery status model`
|
||||||
|
2. `feat(position-roundtable): validate chain activation`
|
||||||
|
3. `feat(position-roundtable): add workflow status graph`
|
||||||
|
4. `feat(position-roundtable): add deliverable cards and preview`
|
||||||
|
5. `feat(position-roundtable): support rejection cascade`
|
||||||
|
6. `feat(position-roundtable): add summary builtin node`
|
||||||
|
7. `feat(position-roundtable): add action planning builtin node`
|
||||||
|
8. `test(position-roundtable): cover delivery workflow`
|
||||||
|
|
||||||
|
## 8. 第一轮建议范围
|
||||||
|
|
||||||
|
第一轮优先做最小可闭环:
|
||||||
|
|
||||||
|
```text
|
||||||
|
意图识别
|
||||||
|
→ 启动校验
|
||||||
|
→ 业务链节点状态
|
||||||
|
→ 节点产物列表
|
||||||
|
→ 产物详情查看
|
||||||
|
→ 驳回与级联失效
|
||||||
|
→ 全部交付后生成方案总结
|
||||||
|
→ 基于总结生成行动规划
|
||||||
|
```
|
||||||
|
|
||||||
|
如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>CM 助手</title>
|
<title>CM 助手</title>
|
||||||
<script src="/runtime-config.js"></script>
|
<script src="/runtime-config.js" data-runtime-config></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"mermaid": "^11.15.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"lightningcss": "^1.30.2",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^4.0.15",
|
"tailwindcss": "^4.0.15",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -460,37 +462,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
||||||
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="
|
"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": {
|
"node_modules/@chevrotain/types": {
|
||||||
"version": "12.0.0",
|
"version": "11.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz",
|
||||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="
|
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||||
},
|
"license": "Apache-2.0"
|
||||||
"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=="
|
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/autocomplete": {
|
"node_modules/@codemirror/autocomplete": {
|
||||||
"version": "6.20.1",
|
"version": "6.20.1",
|
||||||
@@ -1732,11 +1708,12 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
|
||||||
},
|
},
|
||||||
"node_modules/@mermaid-js/parser": {
|
"node_modules/@mermaid-js/parser": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"langium": "^4.0.0"
|
"@chevrotain/types": "~11.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/api": {
|
"node_modules/@opentelemetry/api": {
|
||||||
@@ -4274,6 +4251,66 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
"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"
|
"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": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -6547,6 +6558,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -7354,7 +7375,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/katex": {
|
"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": [
|
"funding": [
|
||||||
"https://opencollective.com/katex",
|
"https://opencollective.com/katex",
|
||||||
"https://github.com/sponsors/katex"
|
"https://github.com/sponsors/katex"
|
||||||
@@ -7372,23 +7395,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
"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": {
|
"node_modules/langsmith": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.0.tgz",
|
||||||
@@ -8100,43 +8106,32 @@
|
|||||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||||
},
|
},
|
||||||
"node_modules/mermaid": {
|
"node_modules/mermaid": {
|
||||||
"version": "11.14.0",
|
"version": "11.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz",
|
||||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.1",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
"@iconify/utils": "^3.0.2",
|
"@iconify/utils": "^3.0.2",
|
||||||
"@mermaid-js/parser": "^1.1.0",
|
"@mermaid-js/parser": "^1.2.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@upsetjs/venn.js": "^2.0.0",
|
"@upsetjs/venn.js": "^2.0.0",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.3",
|
||||||
"cytoscape-cose-bilkent": "^4.1.0",
|
"cytoscape-cose-bilkent": "^4.1.0",
|
||||||
"cytoscape-fcose": "^2.2.0",
|
"cytoscape-fcose": "^2.2.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"d3-sankey": "^0.12.3",
|
||||||
"dagre-d3-es": "7.0.14",
|
"dagre-d3-es": "7.0.14",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.3",
|
||||||
"katex": "^0.16.25",
|
"es-toolkit": "^1.45.1",
|
||||||
|
"katex": "^0.16.45",
|
||||||
"khroma": "^2.1.0",
|
"khroma": "^2.1.0",
|
||||||
"lodash-es": "^4.17.23",
|
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"roughjs": "^4.6.6",
|
"roughjs": "^4.6.6",
|
||||||
"stylis": "^4.3.6",
|
"stylis": "^4.3.6",
|
||||||
"ts-dedent": "^2.2.0",
|
"ts-dedent": "^2.2.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
@@ -10414,49 +10409,6 @@
|
|||||||
"vite": ">=5.0.0"
|
"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": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 967 KiB |
|
Before Width: | Height: | Size: 1000 KiB |
|
Before Width: | Height: | Size: 959 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 543 KiB |
|
Before Width: | Height: | Size: 582 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: 966 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;如某个环境不用某项,可以留空字符串。
|
// 4. 这里的默认值来自 .env.network;如某个环境不用某项,可以留空字符串。
|
||||||
// 5. 布尔开关也用字符串形式填写:"true" / "false"。
|
// 5. 布尔开关也用字符串形式填写:"true" / "false"。
|
||||||
window.__CM_RUNTIME_CONFIG__ = {
|
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 等。
|
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
// 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。
|
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||||
VITE_CANVAS_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",
|
VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000",
|
||||||
|
|
||||||
@@ -85,12 +113,23 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
|
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
|
||||||
VITE_MOCK_COP_TASK_DETAIL: "false",
|
VITE_MOCK_COP_TASK_DETAIL: "false",
|
||||||
|
|
||||||
|
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
|
||||||
|
// 默认关闭,避免演示历史记录出现在正常生产账号中。
|
||||||
|
VITE_POSITION_ROUNDTABLE_DEMO_MODE: "false",
|
||||||
|
// 纯前端岗位分析演示:跳过登录,直接进入岗位分析页,并禁止所有后台请求。
|
||||||
|
// 仅用于离线展台/演示环境,生产环境必须保持 false。
|
||||||
|
VITE_POSITION_ROUNDTABLE_STANDALONE_MODE: "false",
|
||||||
|
|
||||||
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
|
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
|
||||||
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
|
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
|
||||||
|
|
||||||
// 静态站点模式开关;一般部署业务系统时保持 false。
|
// 静态站点模式开关;一般部署业务系统时保持 false。
|
||||||
VITE_STATIC_WEBSITE_ONLY: "false",
|
VITE_STATIC_WEBSITE_ONLY: "false",
|
||||||
|
|
||||||
|
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
|
||||||
|
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
|
||||||
|
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
|
||||||
|
|
||||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||||
};
|
};
|
||||||
@@ -37,6 +37,11 @@ import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
|||||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
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 { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
|
|
||||||
@@ -46,6 +51,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
const locale = useMemo(() => detectLocale(), []);
|
const locale = useMemo(() => detectLocale(), []);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const publicEmbedTheme = usePublicEmbedTheme();
|
const publicEmbedTheme = usePublicEmbedTheme();
|
||||||
|
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
|
||||||
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
|
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
|
||||||
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
|
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,14 +60,18 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
|
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
|
||||||
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
|
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (positionStandaloneMode) return;
|
||||||
void fetchYLogConfig();
|
void fetchYLogConfig();
|
||||||
}, []);
|
}, [positionStandaloneMode]);
|
||||||
const forcedTheme = useMemo(() => {
|
const forcedTheme = useMemo(() => {
|
||||||
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
|
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
|
||||||
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
|
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
|
||||||
const embedTheme = resolveForcedEmbedTheme(location.search);
|
const embedTheme = resolveForcedEmbedTheme(location.search);
|
||||||
if (embedTheme) return embedTheme;
|
if (embedTheme) return embedTheme;
|
||||||
const routeTheme = resolveForcedThemeForPath(location.pathname);
|
const routeTheme = resolveForcedThemeForPath(
|
||||||
|
location.pathname,
|
||||||
|
location.search,
|
||||||
|
);
|
||||||
if (routeTheme) return routeTheme;
|
if (routeTheme) return routeTheme;
|
||||||
if (location.pathname === "/embed/roundtable") {
|
if (location.pathname === "/embed/roundtable") {
|
||||||
const wp = readWujieProps();
|
const wp = readWujieProps();
|
||||||
@@ -86,7 +96,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
attribute="class"
|
attribute="class"
|
||||||
storageKey="strategy-theme"
|
storageKey="strategy-theme"
|
||||||
defaultTheme="light"
|
defaultTheme="light"
|
||||||
themes={["light", "dark", "lightblue"]}
|
themes={["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"]}
|
||||||
enableSystem={false}
|
enableSystem={false}
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
@@ -95,8 +105,8 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
<I18nProvider initialLocale={locale}>
|
<I18nProvider initialLocale={locale}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
<AuthSyncWatcher />
|
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||||
<DesensitizeWordsLoader />
|
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||||
<FontScaleApplier />
|
<FontScaleApplier />
|
||||||
{children}
|
{children}
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
@@ -114,17 +124,38 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
*/
|
*/
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
|
if (isPositionRoundtableStandaloneMode()) {
|
||||||
|
return <Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />;
|
||||||
|
}
|
||||||
return <Navigate to={`/login${search}`} replace />;
|
return <Navigate to={`/login${search}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
|
||||||
|
const standaloneLoginRedirect = (
|
||||||
|
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
|
||||||
|
);
|
||||||
|
const pageElement = positionStandaloneMode ? (
|
||||||
|
<ChatRuntime>
|
||||||
|
<PositionRoundtablePage compactChrome />
|
||||||
|
</ChatRuntime>
|
||||||
|
) : (
|
||||||
|
<PageRoutes />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRedirect />} />
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route
|
||||||
<Route path="/login/:username" element={<LoginPage />} />
|
path="/login"
|
||||||
|
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/login/:username"
|
||||||
|
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||||
|
/>
|
||||||
<Route path="/landing" element={<LandingPage />} />
|
<Route path="/landing" element={<LandingPage />} />
|
||||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||||
@@ -161,7 +192,7 @@ export function App() {
|
|||||||
path="/roundtable-dashboard"
|
path="/roundtable-dashboard"
|
||||||
element={<DashboardStandalonePage />}
|
element={<DashboardStandalonePage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/page/*" element={<PageRoutes />} />
|
<Route path="/page/*" element={pageElement} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppProviders>
|
</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;
|
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 {
|
.sidebar-contents {
|
||||||
width: 98px;
|
width: 98px;
|
||||||
min-height: calc(100% - 20px);
|
min-height: calc(100% - 20px);
|
||||||
@@ -564,6 +575,37 @@
|
|||||||
background: #ffffff;
|
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)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
||||||
.light-sidebar {
|
.light-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -587,6 +629,12 @@
|
|||||||
width: 236px;
|
width: 236px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||||
|
.light-sidebar--header-hidden {
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||||
.light-sidebar__primary {
|
.light-sidebar__primary {
|
||||||
flex: 0 0 96px;
|
flex: 0 0 96px;
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ type PageSidebarDarkProps = {
|
|||||||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
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 isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||||
const inWujieEnv = isWujieEnv();
|
const inWujieEnv = isWujieEnv();
|
||||||
@@ -196,7 +202,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
|
// 收起时主侧栏不占宽度,保留左下角入口以便随时恢复。
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -207,13 +213,17 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
|||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
>
|
>
|
||||||
<PanelLeftOpen className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
<PanelLeftOpen className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
||||||
<span className="sidebar-collapse-btn__label">收起面板</span>
|
<span className="sidebar-collapse-btn__label">展开面板</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="sidebar-contents">
|
||||||
{sidebarMenuItemsToRender.map((item) => (
|
{sidebarMenuItemsToRender.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -244,7 +254,6 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="收起侧边栏"
|
title="收起侧边栏"
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import "@/components/page-sdebar.css";
|
|||||||
type PageSidebarProps = {
|
type PageSidebarProps = {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
|
/** Global Header is hidden but this sidebar remains available. */
|
||||||
|
headerHidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||||
@@ -79,23 +81,28 @@ const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
|||||||
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
||||||
* (保证嵌入模式下所有层级仍可达,零回归)。
|
* (保证嵌入模式下所有层级仍可达,零回归)。
|
||||||
*/
|
*/
|
||||||
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
|
export function PageSidebar({
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapsed,
|
||||||
|
headerHidden = false,
|
||||||
|
}: PageSidebarProps) {
|
||||||
if (isWujieEnv()) {
|
if (isWujieEnv()) {
|
||||||
return (
|
return (
|
||||||
<LegacyAccordionSidebar
|
<LegacyAccordionSidebar
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={onToggleCollapsed}
|
onToggleCollapsed={onToggleCollapsed}
|
||||||
|
headerHidden={headerHidden}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <SecondaryNavSidebar />;
|
return <SecondaryNavSidebar headerHidden={headerHidden} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||||||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||||||
*/
|
*/
|
||||||
function SecondaryNavSidebar() {
|
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -150,7 +157,11 @@ function SecondaryNavSidebar() {
|
|||||||
open={notificationOpen}
|
open={notificationOpen}
|
||||||
onOpenChange={setNotificationOpen}
|
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">
|
<div className="light-sidebar__primary">
|
||||||
<nav className="light-sidebar__list" aria-label="二级菜单">
|
<nav className="light-sidebar__list" aria-label="二级菜单">
|
||||||
@@ -287,6 +298,7 @@ function SecondaryNavSidebar() {
|
|||||||
function LegacyAccordionSidebar({
|
function LegacyAccordionSidebar({
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
|
headerHidden,
|
||||||
}: PageSidebarProps) {
|
}: PageSidebarProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
@@ -438,7 +450,9 @@ function LegacyAccordionSidebar({
|
|||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<aside
|
<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
|
isDark
|
||||||
? "border-r border-blue-800/30 bg-slate-900"
|
? "border-r border-blue-800/30 bg-slate-900"
|
||||||
: "border-r border-red-100 bg-white"
|
: "border-r border-red-100 bg-white"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
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:
|
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",
|
"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:
|
outline:
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ const DialogContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
|
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
||||||
|
overlayClassName?: string;
|
||||||
}
|
}
|
||||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay className={overlayClassName} />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function SidebarProvider({
|
|||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
open: openProp,
|
open: openProp,
|
||||||
onOpenChange: setOpenProp,
|
onOpenChange: setOpenProp,
|
||||||
|
disableToggle = false,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
@@ -69,6 +70,8 @@ function SidebarProvider({
|
|||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** Keep a controlled deployment-level rail from being reopened by hotkeys. */
|
||||||
|
disableToggle?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [openMobile, setOpenMobile] = React.useState(false);
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
@@ -94,8 +97,9 @@ function SidebarProvider({
|
|||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
const toggleSidebar = React.useCallback(() => {
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
if (disableToggle) return;
|
||||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||||
}, [isMobile, setOpen, setOpenMobile]);
|
}, [disableToggle, isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
React.useEffect(() => {
|
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;
|
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. */
|
/* Anchor target offset so headings clear the floating "目录" pill. */
|
||||||
.wiki-markdown h1,
|
.wiki-markdown h1,
|
||||||
.wiki-markdown h2,
|
.wiki-markdown h2,
|
||||||
|
|||||||
@@ -24,12 +24,60 @@ const AGENT_COLORS_DARK = [
|
|||||||
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
|
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 按 agent id 稳定散列出一对渐变色(from → to)。`isDark` 切换深蓝/浅色调色板。 */
|
/** 按 agent id 稳定散列出一对渐变色,并根据当前主题返回可读的图标前景色。 */
|
||||||
export function agentColor(id: string, isDark: boolean) {
|
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;
|
let hash = 0;
|
||||||
for (let i = 0; i < id.length; i++) {
|
for (let i = 0; i < id.length; i++) {
|
||||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
}
|
}
|
||||||
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
|
const palette =
|
||||||
return palette[hash % palette.length]!;
|
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,90 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { BotIcon, SparklesIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type AgentBrandIconProps = {
|
|
||||||
className?: string;
|
|
||||||
size?: "nav" | "sm" | "md" | "lg";
|
|
||||||
};
|
|
||||||
|
|
||||||
const SIZE_STYLES = {
|
|
||||||
nav: {
|
|
||||||
frame: "size-[22px] rounded-md",
|
|
||||||
bot: "size-3.5",
|
|
||||||
badge: "-right-1 -top-1 size-2.5",
|
|
||||||
sparkle: "size-1.5",
|
|
||||||
},
|
|
||||||
sm: {
|
|
||||||
frame: "size-10 rounded-full",
|
|
||||||
bot: "size-[18px]",
|
|
||||||
badge: "-right-1 -top-1 size-3.5",
|
|
||||||
sparkle: "size-2",
|
|
||||||
},
|
|
||||||
md: {
|
|
||||||
frame: "size-12 rounded-full",
|
|
||||||
bot: "size-6",
|
|
||||||
badge: "-right-1 -top-1 size-4",
|
|
||||||
sparkle: "size-2.5",
|
|
||||||
},
|
|
||||||
lg: {
|
|
||||||
frame: "size-16 rounded-full",
|
|
||||||
bot: "size-7",
|
|
||||||
badge: "-right-1 -top-1 size-[18px]",
|
|
||||||
sparkle: "size-3",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function AgentBrandIcon({
|
|
||||||
className,
|
|
||||||
size = "md",
|
|
||||||
}: AgentBrandIconProps) {
|
|
||||||
const styles = SIZE_STYLES[size];
|
|
||||||
const showBadge = size === "nav";
|
|
||||||
|
|
||||||
if (size !== "nav") {
|
|
||||||
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="/agent-avatar.png"
|
|
||||||
alt=""
|
|
||||||
className="size-full object-contain"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
data-agent-brand-icon={size}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex shrink-0 items-center justify-center border border-blue-100 bg-[radial-gradient(circle_at_35%_30%,#ffffff_0%,#edf4ff_46%,#dce9ff_100%)] text-[#1f5fdb] shadow-[0_8px_22px_rgba(37,99,235,0.13),inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-blue-400/30 dark:bg-blue-500/20 dark:text-blue-300",
|
|
||||||
styles.frame,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BotIcon className={styles.bot} strokeWidth={1.9} />
|
|
||||||
{showBadge && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute flex items-center justify-center rounded-full bg-gradient-to-br from-[#4b91ff] to-[#1858db] text-white shadow-sm ring-2 ring-white dark:ring-slate-950",
|
|
||||||
styles.badge,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SparklesIcon className={styles.sparkle} strokeWidth={2.4} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BotIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
MoreHorizontalIcon,
|
|
||||||
PinIcon,
|
PinIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
@@ -46,13 +46,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -67,7 +60,6 @@ import { usePublishSquares } from "@/core/system-settings/hooks";
|
|||||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
|
||||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||||
@@ -559,7 +551,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
{isOwned && (
|
{isOwned && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
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"
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
>
|
>
|
||||||
我的
|
我的
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -567,7 +559,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
{isFromOthers && (
|
{isFromOthers && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-blue-200 bg-blue-50/60 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-500/10 dark:text-blue-300"
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
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 && (
|
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-sky-200 bg-sky-50/60 text-xs text-sky-700 dark:border-sky-800 dark:bg-sky-500/10 dark:text-sky-300"
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
title="该智能体已对所有用户可见"
|
title="该智能体已对所有用户可见"
|
||||||
>
|
>
|
||||||
<GlobeIcon className="size-3" />
|
<GlobeIcon className="size-3" />
|
||||||
@@ -672,11 +664,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
...(agent.tool_groups ?? []).map((group) => ({
|
...(agent.tool_groups ?? []).map((group) => ({
|
||||||
key: `tg:${group}`,
|
key: `tg:${group}`,
|
||||||
node: (
|
node: (
|
||||||
<Badge
|
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||||
key={`tg:${group}`}
|
|
||||||
variant="outline"
|
|
||||||
className="border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
|
|
||||||
>
|
|
||||||
{desensitize(group)}
|
{desensitize(group)}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
@@ -687,7 +675,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
<Badge
|
<Badge
|
||||||
key={`sk:${skill}`}
|
key={`sk:${skill}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="cursor-pointer border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
|
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||||
title={t.settings.skills.viewDetailAction}
|
title={t.settings.skills.viewDetailAction}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -837,179 +825,78 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
|
|
||||||
const chatButton = (
|
const chatButton = (
|
||||||
<Button
|
<Button
|
||||||
variant={isList ? "default" : "outline"}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(isList ? "shrink-0" : "w-full")}
|
||||||
isList
|
|
||||||
? "shrink-0 rounded-xl bg-[#1f63e9] px-4 font-medium text-white hover:bg-[#1857d4]"
|
|
||||||
: "h-11 w-full rounded-lg border-[#b9d1ff] bg-white font-medium text-[#135ee8] shadow-none hover:border-[#79a8ff] hover:bg-[#f7faff] hover:text-[#135ee8] dark:border-blue-700 dark:bg-slate-900 dark:text-blue-300",
|
|
||||||
)}
|
|
||||||
onClick={handleChat}
|
onClick={handleChat}
|
||||||
>
|
>
|
||||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
开始对话
|
{t.agents.chat}
|
||||||
</Button>
|
</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-blue-50 px-2.5 py-1 text-xs font-normal text-blue-600 hover:bg-blue-50 dark:bg-blue-500/15 dark:text-blue-300">
|
|
||||||
<span className="mr-1.5 size-1.5 rounded-full bg-blue-500" />
|
|
||||||
已发布
|
|
||||||
</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 = (
|
const cardLayout = (
|
||||||
<Card
|
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||||
className={cn(
|
<CardHeader className="min-w-0 pb-2">
|
||||||
"group flex min-h-[366px] 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-[#b8d1ff] hover:!bg-[linear-gradient(135deg,rgba(239,246,255,0.92)_0%,rgba(255,255,255,0.98)_48%,#fff_100%)] hover:!ring-1 hover:!ring-[#e2edff] hover:shadow-[0_8px_24px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900",
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
agent.featured_order != null && "border-blue-300 ring-1 ring-blue-100 dark:border-blue-700 dark:ring-blue-900/40",
|
<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" />
|
||||||
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
|
</div>
|
||||||
{agent.featured_order != null ? (
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<>
|
<CardTitle
|
||||||
<div className="mb-1 flex h-7 items-center justify-between">
|
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||||
<Badge className="border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:border-blue-800 dark:bg-blue-500/15 dark:text-blue-300">
|
title={desensitize(agent.name)}
|
||||||
<SparklesIcon className="mr-1 size-3.5 fill-current" />
|
>
|
||||||
推荐
|
{desensitize(agent.name)}
|
||||||
</Badge>
|
</CardTitle>
|
||||||
{cardActionsMenu}
|
{agent.model && (
|
||||||
|
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||||
|
{desensitize(agent.model)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AgentBrandIcon size="lg" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<AgentBrandIcon size="lg" />
|
|
||||||
{cardActionsMenu}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||||
<CardTitle
|
{statusBadges}
|
||||||
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"
|
</div>
|
||||||
title={desensitize(agent.name)}
|
</div>
|
||||||
>
|
|
||||||
{desensitize(agent.name)}
|
|
||||||
</CardTitle>
|
|
||||||
{agent.description && (
|
{agent.description && (
|
||||||
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
|
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||||
{desensitize(agent.description)}
|
{desensitize(agent.description)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
||||||
|
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{(agent.tags?.length ?? 0) > 0 && (
|
{(agent.tags?.length ?? 0) > 0 && (
|
||||||
<CardContent className="px-5 pt-0 pb-1.5">
|
<CardContent className="pt-0 pb-2">
|
||||||
<TagChipList tags={agent.tags!} />
|
<TagChipList tags={agent.tags!} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSkillsOrTools && (
|
{hasSkillsOrTools && (
|
||||||
<CardContent className="px-5 pt-0 pb-2">
|
<CardContent className="pt-0 pb-2">
|
||||||
<ClampedBadgeRow items={skillBadgeItems} />
|
<ClampedBadgeRow items={skillBadgeItems} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-10 px-5 pt-2 pb-4">
|
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
{showActionRow && (
|
||||||
{cardStatusBadge}
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
<span
|
{actionButtons}
|
||||||
className="min-w-0 truncate text-xs text-slate-400"
|
</div>
|
||||||
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
|
)}
|
||||||
>
|
|
||||||
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
|
|
||||||
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{chatButton}
|
{chatButton}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const listLayout = (
|
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-blue-200 hover:shadow-[0_12px_32px_rgba(37,99,235,0.09)] dark:border-slate-800 dark:bg-slate-900">
|
<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]">
|
||||||
<AgentBrandIcon className="agent-avatar" size="md" />
|
<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="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
|||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
||||||
const isActive = agent.id === activeAgentId;
|
const isActive = agent.id === activeAgentId;
|
||||||
const { from, to } = agentColor(agent.id, isDark);
|
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
@@ -279,6 +279,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
|||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
||||||
|
color: foreground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bot className="size-[19px]" />
|
<Bot className="size-[19px]" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BotIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
@@ -42,7 +43,6 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
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 { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||||
@@ -520,7 +520,9 @@ export function AgentDetailPanel({
|
|||||||
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
||||||
<section className="bg-card/60 rounded-xl border p-4">
|
<section className="bg-card/60 rounded-xl border p-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
{editing && draft ? (
|
{editing && draft ? (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||||
ArrowDownUpIcon,
|
|
||||||
BotIcon,
|
|
||||||
Loader2,
|
|
||||||
LockIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
XIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -19,17 +10,17 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Pagination } from "@/components/ui/pagination";
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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";
|
import { AgentCard } from "./agent-card";
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
type AgentStatusFilter = "all" | "published" | "draft" | "private" | "builtin";
|
|
||||||
|
|
||||||
// Within a single tab the verified password is kept in sessionStorage so a
|
// 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
|
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
|
||||||
@@ -41,10 +32,11 @@ export function AgentGallery() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
const [viewMode, setViewMode] = useViewMode("agent");
|
||||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||||
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
|
|
||||||
const { data: publishSquares } = usePublishSquares();
|
const { data: publishSquares } = usePublishSquares();
|
||||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||||
// backend always returns at least one (the built-in default).
|
// 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.
|
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
|
||||||
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
|
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
|
||||||
// filters change, every tab returns to its first page.
|
// filters change, every tab returns to its first page.
|
||||||
|
const tagKey = tagIds.join(",");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPages({});
|
setPages({});
|
||||||
}, [search, sort, publisher?.id, statusFilter]);
|
}, [search, tagKey, sort, publisher?.id]);
|
||||||
|
|
||||||
// If the active square tab disappears (admin removed that square), fall back
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the gallery never points at a dead tab.
|
// to "我的" so the gallery never points at a dead tab.
|
||||||
@@ -115,6 +108,7 @@ export function AgentGallery() {
|
|||||||
} = useAgentsPaginated({
|
} = useAgentsPaginated({
|
||||||
scope: mainScope,
|
scope: mainScope,
|
||||||
search,
|
search,
|
||||||
|
tagIds,
|
||||||
sort,
|
sort,
|
||||||
page: mainPage,
|
page: mainPage,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
@@ -132,6 +126,7 @@ export function AgentGallery() {
|
|||||||
error: privateError,
|
error: privateError,
|
||||||
} = usePrivateAgentsPaginated(privatePassword, {
|
} = usePrivateAgentsPaginated(privatePassword, {
|
||||||
search,
|
search,
|
||||||
|
tagIds,
|
||||||
sort,
|
sort,
|
||||||
page: pages.private ?? 1,
|
page: pages.private ?? 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
@@ -182,22 +177,21 @@ export function AgentGallery() {
|
|||||||
const handlePublisherClick = (p: { id: string; label: string }) =>
|
const handlePublisherClick = (p: { id: string; label: string }) =>
|
||||||
setPublisher(p);
|
setPublisher(p);
|
||||||
|
|
||||||
const filterByStatus = (agent: Agent) => {
|
const renderGrid = (list: Agent[]) =>
|
||||||
if (statusFilter === "published") return agent.published && !agent.is_private;
|
viewMode === "list" ? (
|
||||||
if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin;
|
<div className="flex flex-col gap-2">
|
||||||
if (statusFilter === "private") return agent.is_private;
|
{list.map((agent) => (
|
||||||
if (statusFilter === "builtin") return agent.builtin;
|
<AgentCard
|
||||||
return true;
|
key={agent.id}
|
||||||
};
|
agent={agent}
|
||||||
|
layout="list"
|
||||||
const renderGrid = (list: Agent[]) => {
|
onPublisherClick={handlePublisherClick}
|
||||||
const visibleAgents = list.filter(filterByStatus);
|
/>
|
||||||
if (visibleAgents.length === 0 && list.length > 0) {
|
))}
|
||||||
return renderEmpty("当前状态筛选下没有智能体。");
|
</div>
|
||||||
}
|
) : (
|
||||||
return (
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
{list.map((agent) => (
|
||||||
{visibleAgents.map((agent) => (
|
|
||||||
<AgentCard
|
<AgentCard
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
@@ -206,7 +200,6 @@ export function AgentGallery() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
||||||
const renderListBody = (opts: {
|
const renderListBody = (opts: {
|
||||||
@@ -271,8 +264,10 @@ export function AgentGallery() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderEmpty = (description: string, showNewButton = false) => (
|
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-blue-200 bg-white/70 text-center shadow-[0_12px_36px_rgba(37,99,235,0.05)] dark:border-blue-900 dark:bg-slate-900/70">
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||||
<AgentBrandIcon size="lg" />
|
<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>
|
<div>
|
||||||
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
|
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
|
||||||
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
|
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
|
||||||
@@ -281,7 +276,8 @@ export function AgentGallery() {
|
|||||||
</div>
|
</div>
|
||||||
{showNewButton && (
|
{showNewButton && (
|
||||||
<Button
|
<Button
|
||||||
className="mt-2 rounded-xl bg-[#1f63e9] px-5 shadow-[0_8px_20px_rgba(31,99,233,0.22)] hover:bg-[#1857d4] dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
|
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}
|
onClick={handleNewAgent}
|
||||||
>
|
>
|
||||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||||
@@ -368,22 +364,17 @@ export function AgentGallery() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full min-h-0 flex-col overflow-hidden border-y border-r border-slate-200/80 bg-white dark:border-slate-800 dark:bg-slate-950">
|
<div className="flex size-full min-h-0 flex-col">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div className="flex shrink-0 items-start justify-between px-[38px] pt-[45px] pb-[38px]">
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[28px] leading-none font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
|
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||||
{t.agents.title}
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
</h1>
|
{t.agents.description}
|
||||||
<p className="mt-4 text-[15px] text-slate-500 dark:text-slate-400">
|
|
||||||
创建并管理专属的 AI 智能体,助力更高效的工作与决策。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={handleNewAgent}>
|
||||||
onClick={handleNewAgent}
|
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||||
className="h-12 w-[154px] rounded-lg bg-gradient-to-r from-[#2467ea] to-[#0f54ed] px-0 text-[15px] font-medium shadow-[0_9px_22px_rgba(31,99,233,0.22)] hover:brightness-105"
|
|
||||||
>
|
|
||||||
<PlusIcon className="mr-2 h-[18px] w-[18px]" />
|
|
||||||
{t.agents.newAgent}
|
{t.agents.newAgent}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,99 +384,43 @@ export function AgentGallery() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setActiveTab(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("");
|
setSearch("");
|
||||||
|
setTagIds([]);
|
||||||
setSort("time_desc");
|
setSort("time_desc");
|
||||||
setStatusFilter("all");
|
|
||||||
setPublisher(null);
|
setPublisher(null);
|
||||||
}}
|
}}
|
||||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="shrink-0 px-[38px]">
|
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||||
<div className="flex min-h-14 flex-col gap-3 border-b border-slate-200/90 lg:flex-row lg:items-end lg:justify-between dark:border-slate-800">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<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">
|
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||||
<TabsTrigger
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
value="mine"
|
|
||||||
className="h-14 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-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
|
||||||
>
|
|
||||||
我的
|
|
||||||
</TabsTrigger>
|
|
||||||
{squareTabs.map((sq) => (
|
{squareTabs.map((sq) => (
|
||||||
<TabsTrigger
|
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||||
key={sq.id}
|
|
||||||
value={sq.id}
|
|
||||||
title={sq.description || sq.name}
|
|
||||||
className="h-14 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-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
|
||||||
>
|
|
||||||
{sq.name}
|
{sq.name}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
{privateFeatureAvailable && (
|
{privateFeatureAvailable && (
|
||||||
<TabsTrigger
|
<TabsTrigger value="private">
|
||||||
value="private"
|
|
||||||
className="h-14 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-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
|
||||||
>
|
|
||||||
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||||
私密广场
|
私密广场
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabsTrigger
|
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
||||||
value="builtin"
|
|
||||||
title="内置/功能型智能体(仅管理员可见)"
|
|
||||||
className="h-14 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-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
|
||||||
>
|
|
||||||
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
||||||
内置智能体
|
内置智能体
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{showSearchBar && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-5 pb-2.5">
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
<div className="relative w-[350px] max-w-full">
|
共 {activeTotal} 个
|
||||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3.5 size-[18px] -translate-y-1/2 text-slate-500" />
|
</span>
|
||||||
<Input
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="搜索智能体名称或描述"
|
|
||||||
className="h-12 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-blue-400 focus-visible:ring-blue-100 dark:border-slate-700 dark:bg-slate-900"
|
|
||||||
/>
|
|
||||||
{(activeTab === "private" ? isPrivateFetching : mainFetching) && (
|
|
||||||
<Loader2 className="absolute top-1/2 right-3.5 size-4 -translate-y-1/2 animate-spin text-blue-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={statusFilter}
|
|
||||||
onValueChange={(value) => setStatusFilter(value as AgentStatusFilter)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-12 w-[156px] 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-12 w-[156px] 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>
|
|
||||||
{activeTab === "private" && privatePassword && (
|
{activeTab === "private" && privatePassword && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -497,16 +432,48 @@ export function AgentGallery() {
|
|||||||
锁定
|
锁定
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{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>
|
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPublisher(null)}
|
onClick={() => setPublisher(null)}
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-300"
|
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="点击清除发布者筛选"
|
title="点击清除发布者筛选"
|
||||||
>
|
>
|
||||||
{publisher.label}
|
{publisher.label}
|
||||||
@@ -514,16 +481,13 @@ export function AgentGallery() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="py-5 text-[15px] text-slate-600 dark:text-slate-300">
|
|
||||||
共 {activeTotal} 个智能体
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{squareTabs.map((sq) => (
|
{squareTabs.map((sq) => (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
key={sq.id}
|
key={sq.id}
|
||||||
value={sq.id}
|
value={sq.id}
|
||||||
className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||||
>
|
>
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
@@ -536,7 +500,7 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<TabsContent value="mine" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
|
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
total: mainTotal,
|
total: mainTotal,
|
||||||
@@ -549,7 +513,7 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabsContent value="builtin" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
|
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
total: mainTotal,
|
total: mainTotal,
|
||||||
@@ -564,7 +528,7 @@ export function AgentGallery() {
|
|||||||
{privateFeatureAvailable && (
|
{privateFeatureAvailable && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="private"
|
value="private"
|
||||||
className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||||
>
|
>
|
||||||
{renderPrivateTab()}
|
{renderPrivateTab()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export function AgentModuleSidebar({
|
|||||||
const renderAgentCard = (agent: Agent, keyPrefix: string) => {
|
const renderAgentCard = (agent: Agent, keyPrefix: string) => {
|
||||||
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
||||||
const isActive = agent.id === activeAgentId;
|
const isActive = agent.id === activeAgentId;
|
||||||
const { from, to } = agentColor(agent.id, isDark);
|
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={`${keyPrefix}:${agent.id}`}
|
key={`${keyPrefix}:${agent.id}`}
|
||||||
@@ -236,7 +236,10 @@ export function AgentModuleSidebar({
|
|||||||
isActive &&
|
isActive &&
|
||||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
"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]" />
|
<Bot className="size-[19px]" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -93,43 +93,47 @@ export function AgentSelector({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
{iconMode ? (
|
||||||
{iconMode ? (
|
<Tooltip content={iconTooltipLabel}>
|
||||||
<Tooltip content={iconTooltipLabel}>
|
<div>
|
||||||
<Button
|
<DialogTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
disabled={disabled}
|
size="icon"
|
||||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
disabled={disabled}
|
||||||
className={cn(
|
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
className={cn(
|
||||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||||
selectedAgent && iconActiveClassName,
|
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||||
iconButtonClassName,
|
selectedAgent && iconActiveClassName,
|
||||||
)}
|
iconButtonClassName,
|
||||||
>
|
)}
|
||||||
<BotIcon className="size-4" />
|
>
|
||||||
{selectedAgent && (
|
<BotIcon className="size-4" />
|
||||||
<span
|
{selectedAgent && (
|
||||||
role="button"
|
<span
|
||||||
tabIndex={0}
|
role="button"
|
||||||
aria-label="清除已选 agent"
|
tabIndex={0}
|
||||||
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"
|
aria-label="清除已选 agent"
|
||||||
onClick={handleClear}
|
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"
|
||||||
onKeyDown={(e) => {
|
onClick={handleClear}
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
onKeyDown={(e) => {
|
||||||
e.preventDefault();
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
onAgentChange(null);
|
e.preventDefault();
|
||||||
}
|
onAgentChange(null);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<XIcon className="size-2.5" />
|
>
|
||||||
</span>
|
<XIcon className="size-2.5" />
|
||||||
)}
|
</span>
|
||||||
</Button>
|
)}
|
||||||
</Tooltip>
|
</Button>
|
||||||
) : (
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<DialogTrigger asChild>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -167,8 +171,8 @@ export function AgentSelector({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
)}
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||||
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
|
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||||
|
|||||||
@@ -45,11 +45,32 @@ function skillLabel(skill: Skill) {
|
|||||||
|
|
||||||
function matchesQuery(skill: Skill, q: string) {
|
function matchesQuery(skill: Skill, q: string) {
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
const haystack = [
|
||||||
skill.name.toLowerCase().includes(q) ||
|
skill.name,
|
||||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
skill.name_zh,
|
||||||
(skill.description ?? "").toLowerCase().includes(q)
|
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({
|
export function SkillTransferSelector({
|
||||||
@@ -159,7 +180,7 @@ export function SkillTransferSelector({
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索全部技能(中英文名称、描述)…"
|
placeholder="搜索技能名称、描述、分类、标签、变迁..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.currentTarget.value);
|
setSearch(e.currentTarget.value);
|
||||||
@@ -239,7 +260,7 @@ export function SkillTransferSelector({
|
|||||||
{/* Two-column transfer panes */}
|
{/* Two-column transfer panes */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<SkillPane
|
<SkillPane
|
||||||
title="可用技能"
|
title="未勾选技能"
|
||||||
count={availableSkills.length}
|
count={availableSkills.length}
|
||||||
items={availableView}
|
items={availableView}
|
||||||
variant="available"
|
variant="available"
|
||||||
@@ -248,10 +269,10 @@ export function SkillTransferSelector({
|
|||||||
onPageChange={setAvailablePage}
|
onPageChange={setAvailablePage}
|
||||||
onPick={add}
|
onPick={add}
|
||||||
onViewDetail={onViewDetail}
|
onViewDetail={onViewDetail}
|
||||||
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
|
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部勾选"}
|
||||||
/>
|
/>
|
||||||
<SkillPane
|
<SkillPane
|
||||||
title="已启用技能"
|
title="已勾选技能"
|
||||||
count={selectedSkills.length}
|
count={selectedSkills.length}
|
||||||
items={selectedView}
|
items={selectedView}
|
||||||
variant="selected"
|
variant="selected"
|
||||||
@@ -260,7 +281,7 @@ export function SkillTransferSelector({
|
|||||||
onPageChange={setSelectedPage}
|
onPageChange={setSelectedPage}
|
||||||
onPick={remove}
|
onPick={remove}
|
||||||
onViewDetail={onViewDetail}
|
onViewDetail={onViewDetail}
|
||||||
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
|
emptyHint={query ? "没有匹配的已选技能" : "尚未勾选任何技能"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
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 { cn } from "@/lib/utils";
|
||||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||||
|
|
||||||
@@ -26,10 +28,13 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
|||||||
*/
|
*/
|
||||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||||
|
|
||||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
|
||||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||||
{ id: "light", label: "红色", color: "#cf1322" },
|
{ id: "light", label: "红色", color: "#cf1322" },
|
||||||
|
{ id: "lightgreen", label: "绿色", color: "#07c05f" },
|
||||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||||
|
{ id: "iceblue", label: "冰川蓝", color: "#9BCEF1" },
|
||||||
|
{ id: "lightyellow", label: "浅黄色", color: "#F3FBD9" },
|
||||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -79,8 +84,23 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
|
|||||||
|
|
||||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { data: appearance } = useAppearanceSettings();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 = () => {
|
const cancelClose = () => {
|
||||||
if (closeTimer.current) {
|
if (closeTimer.current) {
|
||||||
@@ -95,6 +115,8 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
|||||||
|
|
||||||
useEffect(() => () => cancelClose(), []);
|
useEffect(() => () => cancelClose(), []);
|
||||||
|
|
||||||
|
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
@@ -117,9 +139,9 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
|||||||
</Button>
|
</Button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
|
<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="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-black/5 bg-white" />
|
<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" />
|
||||||
{THEME_SWATCHES.map((swatch) => {
|
{swatches.map((swatch) => {
|
||||||
const active = theme === swatch.id;
|
const active = theme === swatch.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -185,6 +207,8 @@ export function LayoutModeSwitcher({
|
|||||||
const current = mode ?? fallback;
|
const current = mode ?? fallback;
|
||||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||||
|
|
||||||
|
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function ArtifactFileDetail({
|
|||||||
onClose,
|
onClose,
|
||||||
allowVirtualMarkdownEdit = false,
|
allowVirtualMarkdownEdit = false,
|
||||||
disableDesensitize = false,
|
disableDesensitize = false,
|
||||||
|
hideFileName = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
filepath: string;
|
filepath: string;
|
||||||
@@ -144,6 +145,7 @@ export function ArtifactFileDetail({
|
|||||||
* 默认 false,沿用全局脱敏。
|
* 默认 false,沿用全局脱敏。
|
||||||
*/
|
*/
|
||||||
disableDesensitize?: boolean;
|
disableDesensitize?: boolean;
|
||||||
|
hideFileName?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
@@ -224,7 +226,8 @@ export function ArtifactFileDetail({
|
|||||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||||
const updateArtifact = useUpdateArtifactContent();
|
const updateArtifact = useUpdateArtifactContent();
|
||||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
const isAutoPreviewLanguage = language === "html" || language === "markdown";
|
||||||
|
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
|
||||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||||
const fileTypeLabel = useMemo(() => {
|
const fileTypeLabel = useMemo(() => {
|
||||||
if (isImageFile(filepath)) return "IMG";
|
if (isImageFile(filepath)) return "IMG";
|
||||||
@@ -268,23 +271,30 @@ export function ArtifactFileDetail({
|
|||||||
setAllowAutoViewSwitch(true);
|
setAllowAutoViewSwitch(true);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
setShowResumeAutoScroll(false);
|
setShowResumeAutoScroll(false);
|
||||||
|
setShowPreviewSwitchPulse(false);
|
||||||
|
}, [filepathFromProps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPreviewStreaming) {
|
||||||
|
setViewMode("code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (language === "html") {
|
if (language === "html") {
|
||||||
setViewMode(thread.isLoading ? "code" : "preview");
|
setViewMode(thread.isLoading ? "code" : "preview");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// md/mermaid 默认预览(AI 写作视图作为可选切换项,由用户手动进入)。
|
|
||||||
if (isSupportPreview) {
|
if (isSupportPreview) {
|
||||||
setViewMode("preview");
|
setViewMode("preview");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]);
|
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allowAutoViewSwitch || language !== "html") {
|
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +310,7 @@ export function ArtifactFileDetail({
|
|||||||
}, 1400);
|
}, 1400);
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [allowAutoViewSwitch, language, thread.isLoading]);
|
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
|
||||||
|
|
||||||
const scrollArtifactToBottom = useCallback(
|
const scrollArtifactToBottom = useCallback(
|
||||||
(behavior: ScrollBehavior = "auto") => {
|
(behavior: ScrollBehavior = "auto") => {
|
||||||
@@ -583,6 +593,7 @@ 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">
|
<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}
|
{fileTypeLabel}
|
||||||
</span>
|
</span>
|
||||||
|
{!hideFileName && (
|
||||||
<ArtifactTitle className="min-w-0">
|
<ArtifactTitle className="min-w-0">
|
||||||
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
|
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
|
||||||
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
||||||
@@ -607,6 +618,7 @@ export function ArtifactFileDetail({
|
|||||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||||
)}
|
)}
|
||||||
</ArtifactTitle>
|
</ArtifactTitle>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||||||
@@ -652,7 +664,7 @@ export function ArtifactFileDetail({
|
|||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
{isHtmlStreaming && (
|
{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]">
|
<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" />
|
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
|
||||||
正在生成,完成后自动预览
|
正在生成,完成后自动预览
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ export function ArtifactFileList({
|
|||||||
className,
|
className,
|
||||||
files,
|
files,
|
||||||
threadId,
|
threadId,
|
||||||
|
onOpenArtifact,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
files: string[];
|
files: string[];
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
onOpenArtifact?: (filepath: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isMock, publicShareCode } = useThread();
|
const { isMock, publicShareCode } = useThread();
|
||||||
@@ -40,10 +42,14 @@ export function ArtifactFileList({
|
|||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(filepath: string) => {
|
(filepath: string) => {
|
||||||
|
if (onOpenArtifact) {
|
||||||
|
onOpenArtifact(filepath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectArtifact(filepath);
|
selectArtifact(filepath);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
},
|
},
|
||||||
[selectArtifact, setOpen],
|
[onOpenArtifact, selectArtifact, setOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInstallSkill = useCallback(
|
const handleInstallSkill = useCallback(
|
||||||
|
|||||||
@@ -1,204 +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 { 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,
|
|
||||||
}: {
|
|
||||||
onCollapse?: () => void;
|
|
||||||
collapsed?: boolean;
|
|
||||||
}) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
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">
|
|
||||||
<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-blue-500/40 dark:hover:bg-slate-900"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/cm-assistant-logo.png"
|
|
||||||
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="flex shrink-0 flex-col items-center gap-1 px-2 py-3" 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-blue-500/40",
|
|
||||||
active
|
|
||||||
? "bg-[#edf4ff] text-[#165ee8] dark:bg-blue-500/15 dark:text-blue-300"
|
|
||||||
: "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-blue-500/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-2 px-6 pt-4 pb-3">
|
|
||||||
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-3.5 outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">
|
|
||||||
<img
|
|
||||||
src="/cm-assistant-logo.png"
|
|
||||||
alt="CM 助手"
|
|
||||||
className="size-10 shrink-0 object-contain"
|
|
||||||
/>
|
|
||||||
<span className="truncate text-[20px] 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-blue-500/40 dark:hover:bg-slate-900 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="shrink-0 px-4 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-11 items-center gap-3 rounded-xl px-4 text-[14px] font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "bg-[#edf4ff] text-[#165ee8] dark:bg-blue-500/15 dark:text-blue-300"
|
|
||||||
: "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";
|
"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 { cn } from "@/lib/utils";
|
||||||
|
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
|
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
|
||||||
@@ -8,18 +14,63 @@ import { cn } from "@/lib/utils";
|
|||||||
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
|
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
|
||||||
*/
|
*/
|
||||||
export function CompactWelcome({ className }: { className?: string }) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h1 className="flex items-center justify-center text-[34px] font-semibold tracking-[-0.035em] text-slate-950 dark:text-slate-50">
|
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
|
||||||
你好,欢迎使用 CM 助手
|
{/* 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>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,12 +46,10 @@ export function CustomPromptBar({
|
|||||||
onInsert,
|
onInsert,
|
||||||
className,
|
className,
|
||||||
currentAgentId,
|
currentAgentId,
|
||||||
compact = false,
|
|
||||||
}: {
|
}: {
|
||||||
onInsert: (content: string) => void;
|
onInsert: (content: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
currentAgentId?: string | null;
|
currentAgentId?: string | null;
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const allPrompts = useCustomPrompts();
|
const allPrompts = useCustomPrompts();
|
||||||
const prompts = allPrompts.filter(
|
const prompts = allPrompts.filter(
|
||||||
@@ -83,13 +81,7 @@ export function CustomPromptBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 items-center gap-1",
|
|
||||||
compact && "min-h-9 justify-end",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{prompts.length > 0 && (
|
{prompts.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{overflowing && (
|
{overflowing && (
|
||||||
@@ -119,7 +111,6 @@ export function CustomPromptBar({
|
|||||||
title={prompt.content}
|
title={prompt.content}
|
||||||
className={cn(
|
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",
|
"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-blue-200 hover:bg-blue-50/60 hover:text-blue-700",
|
|
||||||
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
"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"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||||
"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-blue-200 hover:bg-blue-50 hover:text-blue-700 dark:border-slate-700 dark:bg-slate-900",
|
|
||||||
)}
|
|
||||||
title="管理常用提示词"
|
title="管理常用提示词"
|
||||||
aria-label="管理常用提示词"
|
aria-label="管理常用提示词"
|
||||||
onClick={() => setManageOpen(true)}
|
onClick={() => setManageOpen(true)}
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export function GeneralQaSidebar() {
|
|||||||
onClick={handleNewChatClick}
|
onClick={handleNewChatClick}
|
||||||
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
||||||
className={cn(
|
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",
|
"border border-solid",
|
||||||
"bg-[rgb(var(--legacy-primary-rgb)/14%)] hover:bg-[rgb(var(--legacy-primary-rgb)/24%)]",
|
"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">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
{frequent.map((agent) => {
|
{frequent.map((agent) => {
|
||||||
const isActive = activeAgentId === agent.id;
|
const isActive = activeAgentId === agent.id;
|
||||||
const { from, to } = agentColor(agent.id, isDark);
|
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
@@ -366,7 +366,10 @@ export function GeneralQaSidebar() {
|
|||||||
isActive &&
|
isActive &&
|
||||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
"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]" />
|
<Bot className="size-[19px]" />
|
||||||
</span>
|
</span>
|
||||||
@@ -464,9 +467,9 @@ export function GeneralQaSidebar() {
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
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
|
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",
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
BarChart3Icon,
|
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ClipboardCheckIcon,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
MessageCircleQuestionIcon,
|
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
|
||||||
ShrinkIcon,
|
ShrinkIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -108,57 +104,6 @@ import { Tooltip } from "./tooltip";
|
|||||||
|
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
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;
|
|
||||||
|
|
||||||
|
|
||||||
function getResolvedMode(
|
function getResolvedMode(
|
||||||
mode: InputMode | undefined,
|
mode: InputMode | undefined,
|
||||||
@@ -266,7 +211,6 @@ export function InputBox({
|
|||||||
const wasStreamingRef = useRef(false);
|
const wasStreamingRef = useRef(false);
|
||||||
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
|
|
||||||
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -277,29 +221,6 @@ export function InputBox({
|
|||||||
!hideAdminRecommendedQuestions &&
|
!hideAdminRecommendedQuestions &&
|
||||||
!userPreferences.hide_admin_prompt_prefix &&
|
!userPreferences.hide_admin_prompt_prefix &&
|
||||||
recommendedQuestions.length > 0;
|
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
@@ -335,7 +256,6 @@ export function InputBox({
|
|||||||
() => selectedModel?.supports_thinking ?? false,
|
() => selectedModel?.supports_thinking ?? false,
|
||||||
[selectedModel],
|
[selectedModel],
|
||||||
);
|
);
|
||||||
const effectiveMode = getResolvedMode(context.mode, supportThinking);
|
|
||||||
|
|
||||||
const supportReasoningEffort = useMemo(
|
const supportReasoningEffort = useMemo(
|
||||||
() => selectedModel?.supports_reasoning_effort ?? false,
|
() => selectedModel?.supports_reasoning_effort ?? false,
|
||||||
@@ -609,68 +529,14 @@ export function InputBox({
|
|||||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||||
ref={promptRootRef}
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col",
|
|
||||||
compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{extraHeader && (
|
{extraHeader && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
{extraHeader}
|
{extraHeader}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
|
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||||
<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-blue-600 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-blue-500/15 dark:hover:bg-slate-800 dark:hover:text-blue-300"
|
|
||||||
>
|
|
||||||
<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-slate-300 hover:bg-slate-50 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-blue-500/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
|
||||||
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 && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||||
{recommendedQuestions.map((q) => (
|
{recommendedQuestions.map((q) => (
|
||||||
<button
|
<button
|
||||||
@@ -723,8 +589,7 @@ export function InputBox({
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{isNewThread && (
|
{isNewThread && (
|
||||||
<CustomPromptBar
|
<CustomPromptBar
|
||||||
compact={compact}
|
className="px-1"
|
||||||
className={cn(compact ? "px-2" : "px-1")}
|
|
||||||
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
|
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
|
||||||
onInsert={(content) => {
|
onInsert={(content) => {
|
||||||
const current = textInput.value?.trim() ?? "";
|
const current = textInput.value?.trim() ?? "";
|
||||||
@@ -748,7 +613,7 @@ export function InputBox({
|
|||||||
: "*:data-[slot='input-group']:rounded-3xl",
|
: "*:data-[slot='input-group']:rounded-3xl",
|
||||||
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
||||||
compact && isNewThread
|
compact && isNewThread
|
||||||
? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(37,99,235,0.6),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-blue-300 focus-within:ring-4 focus-within:ring-blue-500/10 dark:border-slate-700 dark:bg-slate-800"
|
? "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 提供白卡 + 旋转描边
|
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||||
isNewThread
|
isNewThread
|
||||||
@@ -772,8 +637,7 @@ export function InputBox({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||||
isNewThread && "min-h-[110px]!",
|
isNewThread && "min-h-[110px]!",
|
||||||
compact &&
|
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||||
"min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
|
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={t.inputBox.placeholder}
|
placeholder={t.inputBox.placeholder}
|
||||||
@@ -784,7 +648,7 @@ export function InputBox({
|
|||||||
<PromptInputFooter
|
<PromptInputFooter
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
"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
|
<PromptInputTools
|
||||||
@@ -920,7 +784,12 @@ export function InputBox({
|
|||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
effectiveMode
|
context.mode === "flash" ||
|
||||||
|
context.mode === "thinking" ||
|
||||||
|
context.mode === "pro" ||
|
||||||
|
context.mode === "ultra"
|
||||||
|
? context.mode
|
||||||
|
: "flash"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PromptInputActionMenuTrigger
|
<PromptInputActionMenuTrigger
|
||||||
@@ -928,38 +797,38 @@ export function InputBox({
|
|||||||
"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",
|
"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 && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
compact &&
|
||||||
(effectiveMode === "thinking"
|
(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"
|
? "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"
|
: 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"
|
? "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"
|
: 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-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"),
|
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{effectiveMode === "flash" && <ZapIcon className="size-3" />}
|
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||||
{effectiveMode === "thinking" && (
|
{context.mode === "thinking" && (
|
||||||
<LightbulbIcon className="size-3" />
|
<LightbulbIcon className="size-3" />
|
||||||
)}
|
)}
|
||||||
{effectiveMode === "pro" && (
|
{context.mode === "pro" && (
|
||||||
<GraduationCapIcon className="size-3" />
|
<GraduationCapIcon className="size-3" />
|
||||||
)}
|
)}
|
||||||
{effectiveMode === "ultra" && (
|
{context.mode === "ultra" && (
|
||||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-normal",
|
"text-xs font-normal",
|
||||||
effectiveMode === "ultra" ? "golden-text" : "",
|
context.mode === "ultra" ? "golden-text" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(effectiveMode === "flash" && t.inputBox.flashMode) ||
|
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||||
(effectiveMode === "thinking" &&
|
(context.mode === "thinking" &&
|
||||||
t.inputBox.reasoningMode) ||
|
t.inputBox.reasoningMode) ||
|
||||||
(effectiveMode === "pro" && t.inputBox.proMode) ||
|
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||||
(effectiveMode === "ultra" && t.inputBox.ultraMode)}
|
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||||
</div>
|
</div>
|
||||||
</PromptInputActionMenuTrigger>
|
</PromptInputActionMenuTrigger>
|
||||||
</ModeHoverGuide>
|
</ModeHoverGuide>
|
||||||
@@ -1256,8 +1125,6 @@ export function InputBox({
|
|||||||
>
|
>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
disabled={models.length === 0}
|
|
||||||
aria-label="模型选择"
|
|
||||||
className={cn(
|
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",
|
"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 &&
|
compact &&
|
||||||
@@ -1271,7 +1138,7 @@ export function InputBox({
|
|||||||
compact && "text-[11px]",
|
compact && "text-[11px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedModel?.display_name || "默认模型"}
|
{selectedModel?.display_name}
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
</div>
|
</div>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
@@ -1305,7 +1172,7 @@ export function InputBox({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
"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 &&
|
compact &&
|
||||||
"size-10 rounded-full border-0 bg-blue-600 text-white shadow-[0_8px_18px_-10px_rgba(37,99,235,0.9)] hover:bg-blue-700 disabled:bg-muted disabled:text-muted-foreground/40 dark:border-0 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:disabled:bg-muted",
|
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useArtifactsOptional } from "../artifacts";
|
import { useArtifactsOptional } from "../artifacts";
|
||||||
import { FlipDisplay } from "../flip-display";
|
import { FlipDisplay } from "../flip-display";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
import {
|
import { SourceCard } from "../skill-display-sources";
|
||||||
isWeKnoraSourcePreviewUrl,
|
|
||||||
SourceCard,
|
|
||||||
} from "../skill-display-sources";
|
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
|
||||||
@@ -654,30 +651,6 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
|
|||||||
sourceLabel = sourceLabel ?? item.url;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function MessageListItem({
|
|||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
onEditSave,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
turnTime,
|
turnTime,
|
||||||
}: {
|
}: {
|
||||||
@@ -154,6 +155,7 @@ export function MessageListItem({
|
|||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
/**
|
/**
|
||||||
* Durable ask time of the turn this message belongs to (the preceding
|
* Durable ask time of the turn this message belongs to (the preceding
|
||||||
@@ -163,11 +165,99 @@ export function MessageListItem({
|
|||||||
*/
|
*/
|
||||||
turnTime?: string;
|
turnTime?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
const messageMarkdown =
|
const messageMarkdown =
|
||||||
extractContentFromMessage(message) ??
|
extractContentFromMessage(message) ??
|
||||||
extractReasoningContentFromMessage(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 (
|
return (
|
||||||
<AIElementMessage
|
<AIElementMessage
|
||||||
id={anchorId}
|
id={anchorId}
|
||||||
@@ -189,6 +279,7 @@ export function MessageListItem({
|
|||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
|
onEditSave={isLoading ? undefined : onEditSave}
|
||||||
onMessageDeleted={onMessageDeleted}
|
onMessageDeleted={onMessageDeleted}
|
||||||
turnTime={turnTime}
|
turnTime={turnTime}
|
||||||
/>
|
/>
|
||||||
@@ -212,6 +303,19 @@ export function MessageListItem({
|
|||||||
fileName="message"
|
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>
|
</div>
|
||||||
</MessageToolbar>
|
</MessageToolbar>
|
||||||
)}
|
)}
|
||||||
@@ -229,25 +333,30 @@ export function MessageListItem({
|
|||||||
* original question stays in the thread).
|
* original question stays in the thread).
|
||||||
*/
|
*/
|
||||||
function HumanMessageBlock({
|
function HumanMessageBlock({
|
||||||
|
messageId,
|
||||||
text,
|
text,
|
||||||
pageContext,
|
pageContext,
|
||||||
filesList,
|
filesList,
|
||||||
messageTime,
|
messageTime,
|
||||||
components,
|
components,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
onEditSave,
|
||||||
deleteButton,
|
deleteButton,
|
||||||
}: {
|
}: {
|
||||||
|
messageId?: string;
|
||||||
text: string;
|
text: string;
|
||||||
pageContext?: EmbeddedPageContext | null;
|
pageContext?: EmbeddedPageContext | null;
|
||||||
filesList: React.ReactNode;
|
filesList: React.ReactNode;
|
||||||
messageTime: { label: string; full: string } | null;
|
messageTime: { label: string; full: string } | null;
|
||||||
components: MarkdownContentProps["components"];
|
components: MarkdownContentProps["components"];
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
deleteButton?: React.ReactNode;
|
deleteButton?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(text);
|
const [draft, setDraft] = useState(text);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
@@ -255,14 +364,27 @@ function HumanMessageBlock({
|
|||||||
}
|
}
|
||||||
}, [text, editing]);
|
}, [text, editing]);
|
||||||
|
|
||||||
const confirmEdit = useCallback(() => {
|
const confirmEdit = useCallback(async () => {
|
||||||
const value = draft.trim();
|
const value = draft.trim();
|
||||||
if (!value) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
onEditResubmit?.(value);
|
onEditResubmit?.(value);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}, [draft, onEditResubmit]);
|
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
@@ -275,7 +397,7 @@ function HumanMessageBlock({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
confirmEdit();
|
void confirmEdit();
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -288,12 +410,17 @@ function HumanMessageBlock({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
disabled={saving}
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => setEditing(false)}
|
||||||
>
|
>
|
||||||
{t.common.cancel}
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
|
<Button
|
||||||
{t.common.confirm}
|
size="sm"
|
||||||
|
onClick={() => void confirmEdit()}
|
||||||
|
disabled={!draft.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,8 +451,8 @@ function HumanMessageBlock({
|
|||||||
{(text || deleteButton) && (
|
{(text || deleteButton) && (
|
||||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
<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 && <CopyButton clipboardData={text} className="size-6" />}
|
||||||
{text && onEditResubmit && (
|
{text && (onEditResubmit || onEditSave) && (
|
||||||
<Tooltip content={t.inputBox.editQuestion}>
|
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -398,6 +525,7 @@ function MessageContent_({
|
|||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
onEditSave,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
turnTime,
|
turnTime,
|
||||||
}: {
|
}: {
|
||||||
@@ -409,6 +537,7 @@ function MessageContent_({
|
|||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
turnTime?: string;
|
turnTime?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -556,12 +685,14 @@ function MessageContent_({
|
|||||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||||
return (
|
return (
|
||||||
<HumanMessageBlock
|
<HumanMessageBlock
|
||||||
|
messageId={message.id}
|
||||||
text={parsedHumanContent.content}
|
text={parsedHumanContent.content}
|
||||||
pageContext={parsedHumanContent.pageContext}
|
pageContext={parsedHumanContent.pageContext}
|
||||||
filesList={filesList}
|
filesList={filesList}
|
||||||
messageTime={messageTime}
|
messageTime={messageTime}
|
||||||
components={components}
|
components={components}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
|
onEditSave={onEditSave}
|
||||||
deleteButton={
|
deleteButton={
|
||||||
canDelete ? (
|
canDelete ? (
|
||||||
<DeleteMessageButton
|
<DeleteMessageButton
|
||||||
|
|||||||
@@ -99,11 +99,15 @@ export function MessageList({
|
|||||||
scrollButtonClassName,
|
scrollButtonClassName,
|
||||||
onClarificationSubmit,
|
onClarificationSubmit,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
onEditSave,
|
||||||
|
messageContentOverrides,
|
||||||
|
externallyHiddenMessageIds,
|
||||||
onSetupAgentApprove,
|
onSetupAgentApprove,
|
||||||
onSetupAgentReject,
|
onSetupAgentReject,
|
||||||
onSetupWritingApprove,
|
onSetupWritingApprove,
|
||||||
onSetupWritingReject,
|
onSetupWritingReject,
|
||||||
onSetupWritingStart,
|
onSetupWritingStart,
|
||||||
|
onOpenArtifact,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@@ -117,12 +121,16 @@ export function MessageList({
|
|||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
onEditResubmit?: (text: string) => 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>;
|
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
onSetupWritingReject?: (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 baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
@@ -155,16 +163,32 @@ export function MessageList({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allMessages = useMemo(
|
const allMessages = useMemo(() => {
|
||||||
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||||
[thread.messages],
|
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 messages = useMemo(
|
||||||
() =>
|
() => {
|
||||||
hiddenMessageIds.size === 0
|
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||||
|
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||||
? allMessages
|
? allMessages
|
||||||
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
: allMessages.filter(
|
||||||
[allMessages, hiddenMessageIds],
|
(m) =>
|
||||||
|
!m.id ||
|
||||||
|
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
@@ -382,6 +406,7 @@ export function MessageList({
|
|||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
|
onEditSave={onEditSave}
|
||||||
onMessageDeleted={
|
onMessageDeleted={
|
||||||
allowDelete ? handleMessageDeleted : undefined
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
}
|
}
|
||||||
@@ -488,7 +513,11 @@ export function MessageList({
|
|||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ArtifactFileList files={files} threadId={threadId} />
|
<ArtifactFileList
|
||||||
|
files={files}
|
||||||
|
threadId={threadId}
|
||||||
|
onOpenArtifact={onOpenArtifact}
|
||||||
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutIcon,
|
LayoutIcon,
|
||||||
|
LeafIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
|
PaletteIcon,
|
||||||
Plus,
|
Plus,
|
||||||
|
SnowflakeIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -18,7 +21,7 @@ import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
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 { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -63,6 +66,7 @@ import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
|||||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||||
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||||
|
|
||||||
|
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
|
||||||
const languageOptions: { value: Locale; label: string }[] = [
|
const languageOptions: { value: Locale; label: string }[] = [
|
||||||
@@ -70,7 +74,7 @@ const languageOptions: { value: Locale; label: string }[] = [
|
|||||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue";
|
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
||||||
|
|
||||||
function makeShortcutId() {
|
function makeShortcutId() {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
@@ -177,6 +181,18 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
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 [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
@@ -212,13 +228,23 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetGlobalDefault = async (
|
const handleSetGlobalDefault = async (
|
||||||
value: "light" | "dark" | "lightblue",
|
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow",
|
||||||
) => {
|
) => {
|
||||||
if (value === globalDefault) return;
|
if (value === globalDefault) return;
|
||||||
try {
|
try {
|
||||||
await updateAppearance.mutateAsync({ default_theme: value });
|
await updateAppearance.mutateAsync({ default_theme: value });
|
||||||
const label =
|
const label =
|
||||||
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
|
value === "light"
|
||||||
|
? "浅色"
|
||||||
|
: value === "dark"
|
||||||
|
? "深色"
|
||||||
|
: value === "lightgreen"
|
||||||
|
? "绿色"
|
||||||
|
: value === "iceblue"
|
||||||
|
? "冰川蓝"
|
||||||
|
: value === "lightyellow"
|
||||||
|
? "浅黄色"
|
||||||
|
: "浅蓝";
|
||||||
toast.success(`已将系统默认主题设置为${label}`);
|
toast.success(`已将系统默认主题设置为${label}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
@@ -374,6 +400,24 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
description: t.settings.appearance.lightblueDescription,
|
description: t.settings.appearance.lightblueDescription,
|
||||||
icon: DropletIcon,
|
icon: DropletIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "lightgreen",
|
||||||
|
label: t.settings.appearance.lightgreen,
|
||||||
|
description: t.settings.appearance.lightgreenDescription,
|
||||||
|
icon: LeafIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iceblue",
|
||||||
|
label: t.settings.appearance.iceblue,
|
||||||
|
description: t.settings.appearance.iceblueDescription,
|
||||||
|
icon: SnowflakeIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lightyellow",
|
||||||
|
label: t.settings.appearance.lightyellow,
|
||||||
|
description: t.settings.appearance.lightyellowDescription,
|
||||||
|
icon: SunIcon,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
t.settings.appearance.dark,
|
t.settings.appearance.dark,
|
||||||
@@ -382,6 +426,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
t.settings.appearance.lightDescription,
|
t.settings.appearance.lightDescription,
|
||||||
t.settings.appearance.lightblue,
|
t.settings.appearance.lightblue,
|
||||||
t.settings.appearance.lightblueDescription,
|
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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,10 +449,26 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
label={option.label}
|
label={option.label}
|
||||||
description={option.description}
|
description={option.description}
|
||||||
active={currentTheme === option.id}
|
active={currentTheme === option.id}
|
||||||
mode={option.id as "light" | "dark" | "lightblue"}
|
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"}
|
||||||
onSelect={(value) => setTheme(value)}
|
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>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
@@ -551,19 +617,69 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
<DropletIcon className="mr-1.5 size-4" />
|
<DropletIcon className="mr-1.5 size-4" />
|
||||||
浅蓝
|
浅蓝
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={globalDefault === "lightgreen" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetGlobalDefault("lightgreen")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<LeafIcon className="mr-1.5 size-4" />
|
||||||
|
绿色
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={globalDefault === "iceblue" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetGlobalDefault("iceblue")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<SnowflakeIcon className="mr-1.5 size-4" />
|
||||||
|
冰川蓝
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={globalDefault === "lightyellow" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetGlobalDefault("lightyellow")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<SunIcon className="mr-1.5 size-4" />
|
||||||
|
浅黄色
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById("custom-theme-library")
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PaletteIcon className="mr-1.5 size-4" />
|
||||||
|
管理自定义主题
|
||||||
|
</Button>
|
||||||
<p className="text-muted-foreground ml-2 text-xs">
|
<p className="text-muted-foreground ml-2 text-xs">
|
||||||
当前系统默认:
|
当前系统默认:{globalDefaultLabel}
|
||||||
{globalDefault === "light"
|
|
||||||
? "浅色"
|
|
||||||
: globalDefault === "dark"
|
|
||||||
? "深色"
|
|
||||||
: "浅蓝"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div id="custom-theme-library" className="scroll-mt-4">
|
||||||
|
<Separator />
|
||||||
|
<SettingsSection
|
||||||
|
title="自定义主题库(管理员)"
|
||||||
|
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
|
||||||
|
>
|
||||||
|
<CustomThemeLibrarySettings />
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -1068,16 +1184,21 @@ function ThemePreviewCard({
|
|||||||
description,
|
description,
|
||||||
active,
|
active,
|
||||||
mode,
|
mode,
|
||||||
|
previewDark,
|
||||||
|
accentColor,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
mode: "light" | "dark" | "lightblue";
|
mode: ThemeType;
|
||||||
onSelect: (mode: "light" | "dark" | "lightblue") => void;
|
previewDark?: boolean;
|
||||||
|
accentColor?: string;
|
||||||
|
onSelect: (mode: ThemeType) => void;
|
||||||
}) {
|
}) {
|
||||||
const previewMode = mode;
|
const previewMode = mode;
|
||||||
|
const isDarkPreview = previewDark ?? previewMode === "dark";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1103,23 +1224,38 @@ function ThemePreviewCard({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||||
previewMode === "dark"
|
isDarkPreview
|
||||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||||
: previewMode === "lightblue"
|
: previewMode === "lightblue"
|
||||||
? "border-blue-200 bg-white text-slate-900"
|
? "border-blue-200 bg-white text-slate-900"
|
||||||
: "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="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2 w-2 rounded-full",
|
"h-2 w-2 rounded-full",
|
||||||
previewMode === "dark"
|
accentColor
|
||||||
? "bg-emerald-400"
|
? ""
|
||||||
: previewMode === "lightblue"
|
: isDarkPreview
|
||||||
? "bg-blue-500"
|
? "bg-emerald-400"
|
||||||
: "bg-emerald-500",
|
: 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-10 rounded-full bg-current/20" />
|
||||||
<div className="h-2 w-6 rounded-full bg-current/15" />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
Upload,
|
Upload,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
WorkflowIcon,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -51,6 +50,13 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty";
|
} from "@/components/ui/empty";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemTitle,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
} from "@/components/ui/item";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -494,6 +500,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
||||||
|
共 {orderedSkills.length} 个技能
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{publisher && (
|
{publisher && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||||
@@ -738,7 +749,7 @@ export function SkillRow({
|
|||||||
|
|
||||||
const badgeGroup = (
|
const badgeGroup = (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<span className={cn(isCard && "truncate font-medium")}>
|
||||||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||||
</span>
|
</span>
|
||||||
{skill.name_zh?.trim() && (
|
{skill.name_zh?.trim() && (
|
||||||
@@ -901,7 +912,7 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
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"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={() => setTagPickerOpen(true)}
|
onClick={() => setTagPickerOpen(true)}
|
||||||
title={t.adminTags.manageTags}
|
title={t.adminTags.manageTags}
|
||||||
>
|
>
|
||||||
@@ -912,7 +923,7 @@ export function SkillRow({
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
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",
|
"h-8 w-8 shrink-0",
|
||||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||||
)}
|
)}
|
||||||
disabled={setFeatured.isPending}
|
disabled={setFeatured.isPending}
|
||||||
@@ -933,7 +944,7 @@ export function SkillRow({
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
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",
|
"h-8 w-8 shrink-0",
|
||||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||||
)}
|
)}
|
||||||
disabled={setPinned.isPending}
|
disabled={setPinned.isPending}
|
||||||
@@ -954,7 +965,7 @@ export function SkillRow({
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
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",
|
"h-8 w-8 shrink-0",
|
||||||
skill.always_on && "text-amber-600 hover:text-amber-700",
|
skill.always_on && "text-amber-600 hover:text-amber-700",
|
||||||
)}
|
)}
|
||||||
disabled={setAlwaysOn.isPending}
|
disabled={setAlwaysOn.isPending}
|
||||||
@@ -993,7 +1004,7 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
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"
|
className="h-8 w-8 shrink-0"
|
||||||
disabled={duplicateSkillMutation.isPending}
|
disabled={duplicateSkillMutation.isPending}
|
||||||
onClick={handleOpenDuplicate}
|
onClick={handleOpenDuplicate}
|
||||||
title="复制为新技能"
|
title="复制为新技能"
|
||||||
@@ -1002,9 +1013,9 @@ export function SkillRow({
|
|||||||
</Button>
|
</Button>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
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"
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
onClick={() => setConfirmDeleteOpen(true)}
|
onClick={() => setConfirmDeleteOpen(true)}
|
||||||
title={t.settings.skills.deleteAction}
|
title={t.settings.skills.deleteAction}
|
||||||
>
|
>
|
||||||
@@ -1014,121 +1025,38 @@ export function SkillRow({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
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-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{isCard ? (
|
{isCard ? (
|
||||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
<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
|
<div
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3.5">
|
{badgeGroup}
|
||||||
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||||
<SkillIcon className="size-6" />
|
{desensitize(skill.description ?? "")}
|
||||||
</div>
|
</p>
|
||||||
<div className="min-w-0 flex-1">
|
{metaNodes}
|
||||||
{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>
|
||||||
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
|
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||||
{actionArea}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="group/skill-card w-full rounded-xl border border-slate-200 bg-white px-5 py-4 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||||
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(150px,0.72fr)_minmax(100px,0.42fr)_minmax(132px,0.58fr)_auto] xl:items-center xl:gap-x-6">
|
<ItemContent
|
||||||
<div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
|
className="cursor-pointer"
|
||||||
<div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
onClick={() => setDetailOpen(true)}
|
||||||
<SkillIcon className="size-7" />
|
>
|
||||||
</div>
|
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<ItemDescription className="line-clamp-4">
|
||||||
{badgeGroup}
|
{desensitize(skill.description ?? "")}
|
||||||
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
|
</ItemDescription>
|
||||||
{desensitize(skill.description ?? "")}
|
{metaNodes}
|
||||||
</p>
|
</ItemContent>
|
||||||
{!isBuiltin && skill.owner_username && (
|
<ItemActions>{actionButtons}</ItemActions>
|
||||||
<button
|
</Item>
|
||||||
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-blue-600 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>
|
|
||||||
)}
|
)}
|
||||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export function SourceCard({
|
|||||||
referenceTarget?: boolean;
|
referenceTarget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showOriginal, setShowOriginal] = useState(false);
|
|
||||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||||
const [contextLoading, setContextLoading] = useState(false);
|
const [contextLoading, setContextLoading] = useState(false);
|
||||||
const [contextError, setContextError] = useState<string | null>(null);
|
const [contextError, setContextError] = useState<string | null>(null);
|
||||||
@@ -82,7 +81,7 @@ export function SourceCard({
|
|||||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !showOriginal || !previewTarget) {
|
if (!open || !previewTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -116,7 +115,7 @@ export function SourceCard({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -129,10 +128,7 @@ export function SourceCard({
|
|||||||
highlighted &&
|
highlighted &&
|
||||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => setOpen(true)}
|
||||||
setShowOriginal(false);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{showReferenceNumber && (
|
{showReferenceNumber && (
|
||||||
@@ -172,15 +168,7 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<Dialog
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) {
|
|
||||||
setShowOriginal(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
@@ -217,27 +205,7 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{previewTarget && (
|
{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 ? (
|
|
||||||
<WeKnoraSourceContextPanel
|
<WeKnoraSourceContextPanel
|
||||||
context={context}
|
context={context}
|
||||||
loading={contextLoading}
|
loading={contextLoading}
|
||||||
@@ -281,27 +249,12 @@ type WeKnoraSourceContext = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
|
|
||||||
return parseWeKnoraPreviewUrl(url) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||||
const normalizedUrl = url
|
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||||
?.trim()
|
|
||||||
.replace(/^[\u200B-\u200D\uFEFF]+/, "");
|
|
||||||
const marker = "/weknora-source-preview?";
|
|
||||||
const start = normalizedUrl?.indexOf(marker) ?? -1;
|
|
||||||
if (start < 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Some MCP tool results wrap the relative pointer in formatting characters
|
const queryIndex = url.indexOf("?");
|
||||||
// or append a newline. Keep accepting that safe internal path, but never
|
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||||
// 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 knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||||
const chunkId = params.get("chunk_id")?.trim();
|
const chunkId = params.get("chunk_id")?.trim();
|
||||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserRound } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
|
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
import { useMyPosition } from "@/core/positions";
|
import { useMyPosition } from "@/core/positions";
|
||||||
@@ -15,13 +15,7 @@ import { cn } from "@/lib/utils";
|
|||||||
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
||||||
* 而非 Header 的白字贴 banner 方案。
|
* 而非 Header 的白字贴 banner 方案。
|
||||||
*/
|
*/
|
||||||
export function UserIdentityBadge({
|
export function UserIdentityBadge({ className }: { className?: string }) {
|
||||||
className,
|
|
||||||
variant = "plain",
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
variant?: "plain" | "topbar";
|
|
||||||
}) {
|
|
||||||
// userInfo belongs to external-app route parameters and can legitimately
|
// userInfo belongs to external-app route parameters and can legitimately
|
||||||
// outlive a login switch. The visible account name must instead match the
|
// outlive a login switch. The visible account name must instead match the
|
||||||
// settings page's current auth identity.
|
// settings page's current auth identity.
|
||||||
@@ -32,33 +26,13 @@ export function UserIdentityBadge({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-workspace-user={variant}
|
className={cn("flex shrink-0 items-center gap-2", className)}
|
||||||
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,
|
|
||||||
)}
|
|
||||||
title={positionName ? `${displayName}·${positionName}` : displayName}
|
title={positionName ? `${displayName}·${positionName}` : displayName}
|
||||||
>
|
>
|
||||||
<div
|
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
|
||||||
className={cn(
|
<User className="size-3.5" />
|
||||||
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
|
|
||||||
variant === "topbar" &&
|
|
||||||
"size-8 bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-[0_4px_12px_-6px_rgba(37,99,235,0.8)] ring-1 ring-blue-500/10 dark:from-blue-400 dark:to-indigo-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
<div
|
<div className="hidden min-w-0 text-left sm:block">
|
||||||
className={cn(
|
|
||||||
"hidden min-w-0 text-left sm:block",
|
|
||||||
variant === "topbar" && "max-w-[165px]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-foreground truncate text-sm font-medium leading-tight">
|
<p className="text-foreground truncate text-sm font-medium leading-tight">
|
||||||
{displayName}
|
{displayName}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { useMemo } from "react";
|
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 { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { AuroraText } from "../ui/aurora-text";
|
import { AuroraText } from "../ui/aurora-text";
|
||||||
@@ -16,6 +17,7 @@ export function Welcome({
|
|||||||
mode?: "ultra" | "pro" | "thinking" | "flash";
|
mode?: "ultra" | "pro" | "thinking" | "flash";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const desensitizeText = useDisplayDesensitizer();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isUltra = useMemo(() => mode === "ultra", [mode]);
|
const isUltra = useMemo(() => mode === "ultra", [mode]);
|
||||||
const colors = useMemo(() => {
|
const colors = useMemo(() => {
|
||||||
@@ -36,7 +38,7 @@ export function Welcome({
|
|||||||
t.welcome.createYourOwnSkill
|
t.welcome.createYourOwnSkill
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
|
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import {
|
|||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
|
||||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
|
||||||
|
|
||||||
export function WorkspaceContainer({
|
export function WorkspaceContainer({
|
||||||
className,
|
className,
|
||||||
@@ -95,15 +93,9 @@ export function WorkspaceHeader({
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4">
|
{rightActions ? (
|
||||||
{rightActions}
|
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
||||||
<ThemeSwitcher tone="content" />
|
) : null}
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
|
|
||||||
/>
|
|
||||||
<UserIdentityBadge variant="topbar" />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,6 +125,5 @@ function nameOfSegment(
|
|||||||
if (!segment) return t.common.home;
|
if (!segment) return t.common.home;
|
||||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||||
if (segment === "chats") return t.breadcrumb.chats;
|
if (segment === "chats") return t.breadcrumb.chats;
|
||||||
if (segment === "agents") return t.agents.title;
|
|
||||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
// 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 { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -23,19 +23,19 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
const NAV_ITEM_CLASS =
|
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 " +
|
"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";
|
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
|
||||||
|
|
||||||
// 「新对话」专属:默认仅主色文字/图标(显眼但不是选中态),命中 /chats/new 时才上主色底
|
// 「新对话」专属:默认仅主色文字/图标(显眼但不是选中态),命中 /chats/new 时才上主色底
|
||||||
const NEW_CHAT_CLASS =
|
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 " +
|
"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";
|
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
|
||||||
|
|
||||||
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
|
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
|
||||||
const NEW_CHAT_COMPACT_CLASS =
|
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%)] " +
|
"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%)] " +
|
"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";
|
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
||||||
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
MessageCircleQuestionIcon,
|
MessageCircleQuestionIcon,
|
||||||
PlugIcon,
|
|
||||||
ScrollTextIcon,
|
ScrollTextIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -219,14 +218,14 @@ export function WorkspaceNavMenu() {
|
|||||||
<TagsIcon />
|
<TagsIcon />
|
||||||
{t.adminTags.title}
|
{t.adminTags.title}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdmin && (
|
{/* {isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/plugins")}
|
onClick={() => navigate("/page/workspace/admin/plugins")}
|
||||||
>
|
>
|
||||||
<PlugIcon />
|
<PlugIcon />
|
||||||
插件管理
|
插件管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)} */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/positions")}
|
onClick={() => navigate("/page/workspace/admin/positions")}
|
||||||
|
|||||||
@@ -2,43 +2,139 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
SidebarHeader,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarRail,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { CmChatSidebar } from "./cm-chat-sidebar";
|
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({
|
export function WorkspaceSidebar({
|
||||||
|
compactChrome = false,
|
||||||
|
forceCollapsed = false,
|
||||||
className,
|
className,
|
||||||
compactChrome: _compactChrome,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
const { open: isSidebarOpen, toggleSidebar } = useSidebar();
|
compactChrome?: boolean;
|
||||||
|
forceCollapsed?: boolean;
|
||||||
|
}) {
|
||||||
|
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 (
|
if (forceCollapsed) {
|
||||||
<div
|
return (
|
||||||
aria-hidden={false}
|
<Sidebar
|
||||||
|
variant="sidebar"
|
||||||
|
collapsible="icon"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SidebarContent>
|
||||||
|
<WorkspaceNavChatList />
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||||
|
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||||
|
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||||
|
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||||
|
if (compactChrome) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden={!isSidebarOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
collapsible="none"
|
collapsible="none"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full border-r border-border",
|
"h-full w-(--sidebar-width) border-r border-border",
|
||||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
useWhiteSidebar &&
|
||||||
"[&_[data-slot=sidebar-container]]:bg-white [&_[data-slot=sidebar-inner]]:bg-white",
|
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<CmChatSidebar onCollapse={toggleSidebar} collapsed={!isSidebarOpen} />
|
{isGeneralQa ? (
|
||||||
|
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||||
|
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||||
|
<GeneralQaSidebar />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||||
|
<RecentChatList compact />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</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 { useTheme as useNextTheme } from 'next-themes';
|
||||||
|
|
||||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||||
|
import {
|
||||||
|
BUILTIN_THEME_IDS,
|
||||||
|
type BuiltinThemeId,
|
||||||
|
type CustomThemePreset,
|
||||||
|
} from '@/core/system-settings/api';
|
||||||
|
import { getCustomThemeTokens, isCustomThemeId } from '@/core/theme/custom-theme';
|
||||||
|
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
|
||||||
|
|
||||||
// 主题类型
|
// 主题类型
|
||||||
export type ThemeType = 'dark' | 'light' | 'lightblue';
|
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
|
||||||
|
|
||||||
// 主题配置
|
// 主题配置
|
||||||
export interface ThemeConfig {
|
export interface ThemeConfig {
|
||||||
type: ThemeType;
|
type: BuiltinThemeId;
|
||||||
name: string;
|
name: string;
|
||||||
colors: {
|
colors: {
|
||||||
background: string;
|
background: string;
|
||||||
@@ -37,7 +44,7 @@ export interface ThemeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 主题配置
|
// 主题配置
|
||||||
const themes: Record<ThemeType, ThemeConfig> = {
|
const themes: Record<BuiltinThemeId, ThemeConfig> = {
|
||||||
dark: {
|
dark: {
|
||||||
type: 'dark',
|
type: 'dark',
|
||||||
name: '深色主题',
|
name: '深色主题',
|
||||||
@@ -104,6 +111,74 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
|||||||
overlay: 'black/30',
|
overlay: 'black/30',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
lightgreen: {
|
||||||
|
type: 'lightgreen',
|
||||||
|
name: '绿色主题',
|
||||||
|
colors: {
|
||||||
|
background: 'white',
|
||||||
|
backgroundSecondary: 'emerald-50',
|
||||||
|
text: 'emerald-950',
|
||||||
|
textSecondary: 'emerald-800',
|
||||||
|
primary: 'emerald-600',
|
||||||
|
primaryHover: 'emerald-500',
|
||||||
|
border: 'emerald-200',
|
||||||
|
accent: 'emerald-700',
|
||||||
|
success: 'emerald-600',
|
||||||
|
warning: 'amber-500',
|
||||||
|
error: 'red-600',
|
||||||
|
card: 'white',
|
||||||
|
cardHover: 'emerald-50',
|
||||||
|
input: 'white',
|
||||||
|
inputBorder: 'emerald-200',
|
||||||
|
overlay: 'black/30',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
iceblue: {
|
||||||
|
type: 'iceblue',
|
||||||
|
name: '冰川蓝主题',
|
||||||
|
colors: {
|
||||||
|
background: 'sky-50',
|
||||||
|
backgroundSecondary: 'sky-100',
|
||||||
|
text: 'slate-800',
|
||||||
|
textSecondary: 'slate-600',
|
||||||
|
// The CSS --primary token is exactly #9BCEF1. Darker blues are used
|
||||||
|
// only for readable text, hover and focus states.
|
||||||
|
primary: 'sky-200',
|
||||||
|
primaryHover: 'sky-300',
|
||||||
|
border: 'sky-200',
|
||||||
|
accent: 'sky-200',
|
||||||
|
success: 'emerald-600',
|
||||||
|
warning: 'amber-600',
|
||||||
|
error: 'red-600',
|
||||||
|
card: 'white',
|
||||||
|
cardHover: 'sky-50',
|
||||||
|
input: 'white',
|
||||||
|
inputBorder: 'sky-200',
|
||||||
|
overlay: 'slate-900/30',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lightyellow: {
|
||||||
|
type: 'lightyellow',
|
||||||
|
name: '浅黄色主题',
|
||||||
|
colors: {
|
||||||
|
background: 'lime-50',
|
||||||
|
backgroundSecondary: 'lime-100',
|
||||||
|
text: 'lime-950',
|
||||||
|
textSecondary: 'lime-800',
|
||||||
|
primary: 'lime-100',
|
||||||
|
primaryHover: 'lime-200',
|
||||||
|
border: 'lime-200',
|
||||||
|
accent: 'lime-100',
|
||||||
|
success: 'emerald-600',
|
||||||
|
warning: 'amber-600',
|
||||||
|
error: 'red-600',
|
||||||
|
card: 'white',
|
||||||
|
cardHover: 'lime-50',
|
||||||
|
input: 'white',
|
||||||
|
inputBorder: 'lime-200',
|
||||||
|
overlay: 'slate-900/30',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'strategy-theme';
|
const STORAGE_KEY = 'strategy-theme';
|
||||||
@@ -114,13 +189,30 @@ const STORAGE_KEY = 'strategy-theme';
|
|||||||
// admin changes. When the flag is present we never override the user.
|
// admin changes. When the flag is present we never override the user.
|
||||||
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
|
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 {
|
function readPersistedTheme(): ThemeType {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
if (saved === 'dark') return 'dark';
|
if (isBuiltinTheme(saved)) return saved;
|
||||||
if (saved === 'lightblue') return 'lightblue';
|
if (isCustomThemeId(saved)) return saved;
|
||||||
return 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +253,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
// a theme yet, this value drives the rendered theme — and re-syncs on
|
// a theme yet, this value drives the rendered theme — and re-syncs on
|
||||||
// every mount so an admin change propagates without users clearing
|
// every mount so an admin change propagates without users clearing
|
||||||
// localStorage by hand.
|
// localStorage by hand.
|
||||||
const { data: appearance } = useAppearanceSettings();
|
const { data: appearance } = useAppearanceSettings({
|
||||||
|
enabled: !isPositionRoundtableStandaloneMode(),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -175,12 +269,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
const current = localStorage.getItem(STORAGE_KEY);
|
const current = localStorage.getItem(STORAGE_KEY);
|
||||||
const legacy = localStorage.getItem('theme');
|
const legacy = localStorage.getItem('theme');
|
||||||
|
|
||||||
if (
|
if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
|
||||||
current &&
|
|
||||||
current !== 'light' &&
|
|
||||||
current !== 'dark' &&
|
|
||||||
current !== 'lightblue'
|
|
||||||
) {
|
|
||||||
// Garbage value — wipe it so the boot-sync below can take over.
|
// Garbage value — wipe it so the boot-sync below can take over.
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
@@ -211,19 +300,47 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
||||||
|
|
||||||
const effectiveTheme = forcedTheme ?? resolvedTheme;
|
const effectiveTheme = forcedTheme ?? resolvedTheme;
|
||||||
const theme: ThemeType = mounted
|
const persistedTheme = readPersistedTheme();
|
||||||
? effectiveTheme === 'dark'
|
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
|
||||||
? 'dark'
|
const customTheme = getEnabledCustomTheme(
|
||||||
: effectiveTheme === 'lightblue'
|
requestedTheme,
|
||||||
? 'lightblue'
|
appearance?.custom_themes,
|
||||||
: 'light'
|
);
|
||||||
: readPersistedTheme();
|
const theme: ThemeType = isBuiltinTheme(requestedTheme)
|
||||||
|
? requestedTheme
|
||||||
|
: customTheme?.id ?? 'light';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
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') {
|
if (theme === 'dark') {
|
||||||
root.setAttribute('theme-mode', 'dark');
|
root.setAttribute('theme-mode', 'dark');
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
@@ -234,12 +351,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
root.removeAttribute('theme-mode');
|
root.removeAttribute('theme-mode');
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
|
|
||||||
if (theme === 'lightblue') {
|
if (
|
||||||
root.setAttribute('data-theme', 'lightblue');
|
theme === 'lightblue' ||
|
||||||
|
theme === 'lightgreen' ||
|
||||||
|
theme === 'iceblue' ||
|
||||||
|
theme === 'lightyellow'
|
||||||
|
) {
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
} else {
|
} else {
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
}
|
}
|
||||||
}, [theme]);
|
}, [customTheme, theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
@@ -251,7 +373,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
markExplicitChoice();
|
markExplicitChoice();
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeConfig = themes[theme];
|
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
@@ -277,6 +399,20 @@ export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string =
|
|||||||
return themeConfig.colors[colorKey];
|
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 => {
|
export const cn = (...classes: (string | undefined | null | false)[]): string => {
|
||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(' ');
|
||||||
|
|||||||
@@ -150,11 +150,14 @@ export function useAllAgentsAdmin(enabled: boolean) {
|
|||||||
return { agents: data ?? [], isLoading, error };
|
return { agents: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgent(id: string | null | undefined) {
|
export function useAgent(
|
||||||
|
id: string | null | undefined,
|
||||||
|
{ enabled = true }: { enabled?: boolean } = {},
|
||||||
|
) {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["agents", id],
|
queryKey: ["agents", id],
|
||||||
queryFn: () => getAgent(id!),
|
queryFn: () => getAgent(id!),
|
||||||
enabled: !!id,
|
enabled: enabled && !!id,
|
||||||
});
|
});
|
||||||
return { agent: data ?? null, isLoading, error };
|
return { agent: data ?? null, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ export async function loadArtifactContent({
|
|||||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||||
const response = await apiFetch(url);
|
const response = await apiFetch(url);
|
||||||
const text = await response.text();
|
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 };
|
return { content: text, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ export function urlOfArtifact({
|
|||||||
publicShareCode,
|
publicShareCode,
|
||||||
)}/files${normalized}`;
|
)}/files${normalized}`;
|
||||||
}
|
}
|
||||||
|
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||||
if (isMock) {
|
if (isMock) {
|
||||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||||
}
|
}
|
||||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||||
@@ -33,5 +34,6 @@ export function extractArtifactsFromThread(thread: AgentThread) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
|
||||||
|
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
|
||||||
}
|
}
|
||||||
@@ -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,3 +1,7 @@
|
|||||||
|
import { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||||
|
|
||||||
|
export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
|
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
|
||||||
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
|
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
|
||||||
@@ -39,6 +43,8 @@ export interface EmbedSession {
|
|||||||
theme: EmbedTheme;
|
theme: EmbedTheme;
|
||||||
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
|
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
|
||||||
hideJump: boolean;
|
hideJump: boolean;
|
||||||
|
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
|
||||||
|
hideHeader: boolean;
|
||||||
/**
|
/**
|
||||||
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
|
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
|
||||||
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
|
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
|
||||||
@@ -75,6 +81,11 @@ export function readHideJumpParam(search: string): FlagParam {
|
|||||||
return readFlagParam(search, "hideJump");
|
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` 三态(仅去全局导航栏 + 侧边栏)。 */
|
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
|
||||||
export function readHideChromeParam(search: string): FlagParam {
|
export function readHideChromeParam(search: string): FlagParam {
|
||||||
return readFlagParam(search, "hideChrome");
|
return readFlagParam(search, "hideChrome");
|
||||||
@@ -85,16 +96,6 @@ export function readIsolateParam(search: string): FlagParam {
|
|||||||
return readFlagParam(search, "isolate");
|
return readFlagParam(search, "isolate");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 把外部传入的主题色映射成离散明/暗。与 `resolveThemeFromQuery` 同源约定(dark-blue → 深色),
|
|
||||||
* 另外宽松接受 `dark` / 中文「深色/深蓝」直传;其余(含空)一律浅色。
|
|
||||||
*/
|
|
||||||
export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme {
|
|
||||||
const v = (raw ?? "").trim().toLowerCase();
|
|
||||||
if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark";
|
|
||||||
return "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
|
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
|
||||||
export function readEmbedThemeParam(search: string): string {
|
export function readEmbedThemeParam(search: string): string {
|
||||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||||
@@ -110,6 +111,7 @@ export function readEmbedSession(): EmbedSession | null {
|
|||||||
embed: p.embed === true,
|
embed: p.embed === true,
|
||||||
theme: p.theme === "dark" ? "dark" : "light",
|
theme: p.theme === "dark" ? "dark" : "light",
|
||||||
hideJump: p.hideJump === true,
|
hideJump: p.hideJump === true,
|
||||||
|
hideHeader: p.hideHeader === true,
|
||||||
hideChrome: p.hideChrome === true,
|
hideChrome: p.hideChrome === true,
|
||||||
isolate: p.isolate === true,
|
isolate: p.isolate === true,
|
||||||
};
|
};
|
||||||
@@ -121,7 +123,7 @@ export function readEmbedSession(): EmbedSession | null {
|
|||||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||||
function writeEmbedSession(next: EmbedSession): void {
|
function writeEmbedSession(next: EmbedSession): void {
|
||||||
try {
|
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);
|
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,6 +143,16 @@ export function isJumpBarHidden(): boolean {
|
|||||||
return readEmbedSession()?.hideJump === true;
|
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 侧边栏)。
|
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
|
||||||
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
|
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
|
||||||
@@ -151,6 +163,10 @@ export function isChromeHidden(): boolean {
|
|||||||
return s?.embed === true || s?.hideChrome === true;
|
return s?.embed === true || s?.hideChrome === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isHideChromeActive(): boolean {
|
||||||
|
return readEmbedSession()?.hideChrome === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
|
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
|
||||||
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
|
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
|
||||||
@@ -168,7 +184,14 @@ export function isIsolatedSession(): boolean {
|
|||||||
export function syncEmbedSessionFromSearch(search: string): void {
|
export function syncEmbedSessionFromSearch(search: string): void {
|
||||||
const cur =
|
const cur =
|
||||||
readEmbedSession() ??
|
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 next: EmbedSession = { ...cur };
|
||||||
|
|
||||||
const embedParam = readEmbedParam(search);
|
const embedParam = readEmbedParam(search);
|
||||||
@@ -183,6 +206,10 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
|||||||
if (hideJumpParam === "on") next.hideJump = true;
|
if (hideJumpParam === "on") next.hideJump = true;
|
||||||
else if (hideJumpParam === "off") next.hideJump = false;
|
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);
|
const hideChromeParam = readHideChromeParam(search);
|
||||||
if (hideChromeParam === "on") next.hideChrome = true;
|
if (hideChromeParam === "on") next.hideChrome = true;
|
||||||
else if (hideChromeParam === "off") next.hideChrome = false;
|
else if (hideChromeParam === "off") next.hideChrome = false;
|
||||||
@@ -234,6 +261,13 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
|
|||||||
export function setIsolatedSession(on: boolean): void {
|
export function setIsolatedSession(on: boolean): void {
|
||||||
const cur =
|
const cur =
|
||||||
readEmbedSession() ??
|
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 });
|
writeEmbedSession({ ...cur, isolate: on });
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -449,10 +449,16 @@ export const enUS: Translations = {
|
|||||||
light: "Light",
|
light: "Light",
|
||||||
dark: "Dark",
|
dark: "Dark",
|
||||||
lightblue: "Light Blue",
|
lightblue: "Light Blue",
|
||||||
|
lightgreen: "Green",
|
||||||
|
iceblue: "Glacier Blue",
|
||||||
|
lightyellow: "Pale Yellow",
|
||||||
systemDescription: "Match the operating system preference automatically.",
|
systemDescription: "Match the operating system preference automatically.",
|
||||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||||
darkDescription: "Dim palette that reduces glare for focus.",
|
darkDescription: "Dim palette that reduces glare for focus.",
|
||||||
lightblueDescription: "A crisp light palette built around blue.",
|
lightblueDescription: "A crisp light palette built around blue.",
|
||||||
|
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
||||||
|
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||||
|
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||||
languageTitle: "Language",
|
languageTitle: "Language",
|
||||||
languageDescription: "Switch between languages.",
|
languageDescription: "Switch between languages.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -384,10 +384,16 @@ export interface Translations {
|
|||||||
light: string;
|
light: string;
|
||||||
dark: string;
|
dark: string;
|
||||||
lightblue: string;
|
lightblue: string;
|
||||||
|
lightgreen: string;
|
||||||
|
iceblue: string;
|
||||||
|
lightyellow: string;
|
||||||
systemDescription: string;
|
systemDescription: string;
|
||||||
lightDescription: string;
|
lightDescription: string;
|
||||||
darkDescription: string;
|
darkDescription: string;
|
||||||
lightblueDescription: string;
|
lightblueDescription: string;
|
||||||
|
lightgreenDescription: string;
|
||||||
|
iceblueDescription: string;
|
||||||
|
lightyellowDescription: string;
|
||||||
languageTitle: string;
|
languageTitle: string;
|
||||||
languageDescription: string;
|
languageDescription: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -430,10 +430,16 @@ export const zhCN: Translations = {
|
|||||||
light: "浅色",
|
light: "浅色",
|
||||||
dark: "深色",
|
dark: "深色",
|
||||||
lightblue: "浅蓝",
|
lightblue: "浅蓝",
|
||||||
|
lightgreen: "绿色",
|
||||||
|
iceblue: "冰川蓝",
|
||||||
|
lightyellow: "浅黄色",
|
||||||
systemDescription: "自动跟随系统主题。",
|
systemDescription: "自动跟随系统主题。",
|
||||||
lightDescription: "更明亮的配色,适合日间使用。",
|
lightDescription: "更明亮的配色,适合日间使用。",
|
||||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||||
|
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||||
|
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||||
|
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||||
languageTitle: "语言",
|
languageTitle: "语言",
|
||||||
languageDescription: "在不同语言之间切换。",
|
languageDescription: "在不同语言之间切换。",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildChatsIframeRedirectPath,
|
||||||
|
buildChatsIframeThreadPath,
|
||||||
|
isExplicitBrainyIframeMode,
|
||||||
isChatsIframeWorkspacePath,
|
isChatsIframeWorkspacePath,
|
||||||
|
resolvePostLoginRedirectPath,
|
||||||
resolveForcedThemeForPath,
|
resolveForcedThemeForPath,
|
||||||
|
resolveStandardChatFallback,
|
||||||
} from "./chats-iframe-route.ts";
|
} from "./chats-iframe-route.ts";
|
||||||
|
|
||||||
test("matches only workspace chats_iframe routes", () => {
|
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);
|
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 {
|
export function isChatsIframeWorkspacePath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === "/page/workspace/chats_iframe/new" ||
|
pathname === CHATS_IFRAME_PATH ||
|
||||||
pathname.startsWith("/page/workspace/chats_iframe/")
|
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(
|
export function resolveForcedThemeForPath(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
): "light" | undefined {
|
search = "",
|
||||||
if (pathname === "/landing" || isChatsIframeWorkspacePath(pathname)) {
|
): "light" | "dark" | undefined {
|
||||||
|
if (pathname === "/landing") {
|
||||||
return "light";
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// id: "position-roundtable",
|
||||||
|
// label: "岗位会商",
|
||||||
|
// icon: Briefcase,
|
||||||
|
// path: "/page/strategy/qa/position-roundtable",
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
id: "dashboard-agent",
|
id: "dashboard-agent",
|
||||||
label: "大屏绘制",
|
label: "大屏绘制",
|
||||||
|
|||||||
@@ -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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
export const BUILTIN_THEME_IDS = [
|
||||||
|
"light",
|
||||||
|
"dark",
|
||||||
|
"lightblue",
|
||||||
|
"lightgreen",
|
||||||
|
"iceblue",
|
||||||
|
"lightyellow",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
|
||||||
|
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
|
||||||
|
export type CustomThemeBaseMode = "light" | "dark";
|
||||||
|
|
||||||
|
export interface CustomThemeColorOverrides {
|
||||||
|
button_hover?: string | null;
|
||||||
|
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 ViewMode = "card" | "list";
|
||||||
export type LayoutMode = "full" | "compact";
|
export type LayoutMode = "full" | "compact";
|
||||||
export type CompactShortcutParamType = "login" | "theme";
|
export type CompactShortcutParamType = "login" | "theme";
|
||||||
@@ -304,6 +337,7 @@ export interface AppearanceSettings {
|
|||||||
default_agent_view: ViewMode;
|
default_agent_view: ViewMode;
|
||||||
default_layout_mode: LayoutMode;
|
default_layout_mode: LayoutMode;
|
||||||
compact_shortcut_buttons: CompactShortcutButton[];
|
compact_shortcut_buttons: CompactShortcutButton[];
|
||||||
|
custom_themes: CustomThemePreset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppearanceUpdate {
|
export interface AppearanceUpdate {
|
||||||
@@ -312,6 +346,7 @@ export interface AppearanceUpdate {
|
|||||||
default_agent_view?: ViewMode;
|
default_agent_view?: ViewMode;
|
||||||
default_layout_mode?: LayoutMode;
|
default_layout_mode?: LayoutMode;
|
||||||
compact_shortcut_buttons?: CompactShortcutButton[];
|
compact_shortcut_buttons?: CompactShortcutButton[];
|
||||||
|
custom_themes?: CustomThemePreset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||||
@@ -469,12 +504,16 @@ export interface ModelConcurrencySettings {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
default_limit: number;
|
default_limit: number;
|
||||||
model_limits: Record<string, number>;
|
model_limits: Record<string, number>;
|
||||||
|
user_default_limit: number;
|
||||||
|
user_model_limits: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelConcurrencyUpdate {
|
export interface ModelConcurrencyUpdate {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
default_limit?: number;
|
default_limit?: number;
|
||||||
model_limits?: Record<string, number>;
|
model_limits?: Record<string, number>;
|
||||||
|
user_default_limit?: number;
|
||||||
|
user_model_limits?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
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({
|
return useQuery({
|
||||||
queryKey: APPEARANCE_KEY,
|
queryKey: APPEARANCE_KEY,
|
||||||
queryFn: () => loadAppearanceSettings(),
|
queryFn: () => loadAppearanceSettings(),
|
||||||
|
enabled,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
// The provider runs even on the login page (where the user has no auth)
|
// 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"]!));
|
||||||
|
}
|
||||||
@@ -35,6 +35,15 @@ export type ThreadStreamOptions = {
|
|||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
assistantId?: string;
|
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 合并)。
|
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||||
threadMetadata?: Record<string, unknown>;
|
threadMetadata?: Record<string, unknown>;
|
||||||
@@ -45,12 +54,46 @@ export type ThreadStreamOptions = {
|
|||||||
|
|
||||||
type SendMessageOptions = {
|
type SendMessageOptions = {
|
||||||
additionalKwargs?: Record<string, unknown>;
|
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"> & {
|
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
|
||||||
title_provisional?: boolean;
|
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 {
|
function normalizeStoredRunId(runId: string | null): string | null {
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -182,6 +225,22 @@ function getStreamErrorMessage(error: unknown): string {
|
|||||||
return "Request failed.";
|
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(
|
async function fetchThreadTitleRefreshState(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
@@ -263,11 +322,24 @@ function isModelConcurrencyLimitError(message: string): boolean {
|
|||||||
return (
|
return (
|
||||||
/\b429\b/.test(message) ||
|
/\b429\b/.test(message) ||
|
||||||
/并发已达到上限/.test(message) ||
|
/并发已达到上限/.test(message) ||
|
||||||
|
/并发调用已达到上限/.test(message) ||
|
||||||
/使用人数较多/.test(message) ||
|
/使用人数较多/.test(message) ||
|
||||||
/too many requests/i.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 {
|
function buildCurrentUserLabel(): string {
|
||||||
const auth = getStoredAuth();
|
const auth = getStoredAuth();
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
@@ -373,6 +445,7 @@ export function useThreadStream({
|
|||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
assistantId,
|
assistantId,
|
||||||
|
runOptions,
|
||||||
threadMetadata,
|
threadMetadata,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
@@ -386,6 +459,12 @@ export function useThreadStream({
|
|||||||
// and to allow access to the current thread id in onUpdateEvent
|
// and to allow access to the current thread id in onUpdateEvent
|
||||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
|
const lastStreamActivityAtRef = useRef(0);
|
||||||
|
const streamReconciliationInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
const markStreamActivity = useCallback(() => {
|
||||||
|
lastStreamActivityAtRef.current = Date.now();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
onStart,
|
onStart,
|
||||||
@@ -420,9 +499,10 @@ export function useThreadStream({
|
|||||||
const handleStreamStart = useCallback(
|
const handleStreamStart = useCallback(
|
||||||
(_threadId: string) => {
|
(_threadId: string) => {
|
||||||
threadIdRef.current = _threadId;
|
threadIdRef.current = _threadId;
|
||||||
|
markStreamActivity();
|
||||||
_handleOnStart(_threadId);
|
_handleOnStart(_threadId);
|
||||||
},
|
},
|
||||||
[_handleOnStart],
|
[_handleOnStart, markStreamActivity],
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -464,6 +544,7 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLangChainEvent(event) {
|
onLangChainEvent(event) {
|
||||||
|
markStreamActivity();
|
||||||
if (event.event === "on_tool_end") {
|
if (event.event === "on_tool_end") {
|
||||||
listeners.current.onToolEnd?.({
|
listeners.current.onToolEnd?.({
|
||||||
name: event.name,
|
name: event.name,
|
||||||
@@ -494,6 +575,7 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdateEvent(data) {
|
onUpdateEvent(data) {
|
||||||
|
markStreamActivity();
|
||||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||||
data || {},
|
data || {},
|
||||||
);
|
);
|
||||||
@@ -517,6 +599,7 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCustomEvent(event: unknown) {
|
onCustomEvent(event: unknown) {
|
||||||
|
markStreamActivity();
|
||||||
if (
|
if (
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
event !== null &&
|
event !== null &&
|
||||||
@@ -563,6 +646,7 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
markStreamActivity();
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
const message = getStreamErrorMessage(error);
|
const message = getStreamErrorMessage(error);
|
||||||
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
|
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
|
||||||
@@ -613,19 +697,17 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是
|
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
||||||
// 把原始的英文/HTTP 错误抛给用户。
|
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
|
||||||
if (isActiveRunConflictError(message)) {
|
const friendly = friendlySendLimitMessage(message);
|
||||||
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
|
if (friendly) {
|
||||||
return;
|
toast.info(friendly);
|
||||||
}
|
|
||||||
if (isModelConcurrencyLimitError(message)) {
|
|
||||||
toast.info("当前使用人数较多,请稍等一下再试。");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
},
|
},
|
||||||
onFinish(state) {
|
onFinish(state) {
|
||||||
|
markStreamActivity();
|
||||||
listeners.current.onFinish?.(state.values);
|
listeners.current.onFinish?.(state.values);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
},
|
},
|
||||||
@@ -654,6 +736,95 @@ export function useThreadStream({
|
|||||||
const threadStreamRef = useRef(thread);
|
const threadStreamRef = useRef(thread);
|
||||||
threadStreamRef.current = 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
|
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||||
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
||||||
@@ -965,31 +1136,50 @@ export function useThreadStream({
|
|||||||
...extraContext,
|
...extraContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
await thread.submit(
|
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
||||||
{
|
const submittedMessages: Message[] = [
|
||||||
messages: [
|
...hiddenContextMessages.map((item) => ({
|
||||||
|
type: "human" as const,
|
||||||
|
...(item.name ? { name: item.name } : {}),
|
||||||
|
content: [
|
||||||
{
|
{
|
||||||
type: "human",
|
type: "text" as const,
|
||||||
content: [
|
text: item.text,
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
additional_kwargs: {
|
|
||||||
...options?.additionalKwargs,
|
|
||||||
client_ts: clientTs,
|
|
||||||
...(filesForSubmit.length > 0
|
|
||||||
? { files: filesForSubmit }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
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,
|
threadId: threadId,
|
||||||
streamSubgraphs: true,
|
streamSubgraphs: true,
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
|
onDisconnect: runOptions?.onDisconnect,
|
||||||
|
multitaskStrategy: runOptions?.multitaskStrategy,
|
||||||
config: {
|
config: {
|
||||||
recursion_limit: 1000,
|
recursion_limit: 1000,
|
||||||
},
|
},
|
||||||
@@ -1018,6 +1208,26 @@ export function useThreadStream({
|
|||||||
);
|
);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
} catch (error) {
|
} 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([]);
|
setOptimisticMessages([]);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1025,7 +1235,7 @@ export function useThreadStream({
|
|||||||
sendInFlightRef.current = false;
|
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
|
// Merge thread with optimistic messages for display
|
||||||
@@ -1334,6 +1544,86 @@ export function useDeleteMessage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Merge metadata into a conversation's record via the Gateway
|
||||||
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
||||||
|
|||||||
@@ -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 步报告、流程图与放大弹窗的样式。 */
|
||||||
@@ -29,5 +29,10 @@ export const env = {
|
|||||||
NEXT_PUBLIC_NOTE_API_BASE_URL: readRuntimeEnv("VITE_NOTE_API_BASE_URL"),
|
NEXT_PUBLIC_NOTE_API_BASE_URL: readRuntimeEnv("VITE_NOTE_API_BASE_URL"),
|
||||||
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readRuntimeEnv("VITE_AIPI_BACKENP_URL"),
|
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readRuntimeEnv("VITE_AIPI_BACKENP_URL"),
|
||||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"),
|
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||||
|
/**
|
||||||
|
* Deployment-level compact navigation mode. This is intentionally a runtime
|
||||||
|
* flag so operations can switch the shell without rebuilding the frontend.
|
||||||
|
*/
|
||||||
|
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
|
||||||
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||||
};
|
};
|
||||||
@@ -4,6 +4,7 @@ import "@fontsource/noto-sans-sc/chinese-simplified-600.css";
|
|||||||
import "@fontsource/noto-sans-sc/chinese-simplified-700.css";
|
import "@fontsource/noto-sans-sc/chinese-simplified-700.css";
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
import "tdesign-react/es/style/index.css";
|
import "tdesign-react/es/style/index.css";
|
||||||
|
import "./styles/tdesign-theme.css";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||||
import {
|
|
||||||
WorkspaceBody,
|
|
||||||
WorkspaceContainer,
|
|
||||||
} from "@/components/workspace/workspace-container";
|
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
return (
|
return <AgentGallery />;
|
||||||
<WorkspaceContainer className="bg-white dark:bg-slate-950">
|
|
||||||
<WorkspaceBody>
|
|
||||||
<AgentGallery />
|
|
||||||
</WorkspaceBody>
|
|
||||||
</WorkspaceContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,9 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
|||||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||||
import { Welcome } from "@/components/workspace/welcome";
|
import { Welcome } from "@/components/workspace/welcome";
|
||||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||||
|
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||||
|
import { isHideChromeActive } from "@/core/embed/embed-session";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PanelLeftOpen } from "lucide-react";
|
import { PanelLeftOpen } from "lucide-react";
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
useThreadSettings,
|
useThreadSettings,
|
||||||
} from "@/core/settings";
|
} from "@/core/settings";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
|
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
@@ -98,9 +101,16 @@ export default function ChatPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
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 } =
|
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||||
useSidebar();
|
useSidebar();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
|
||||||
|
const [effectiveLayoutMode] = useLayoutMode();
|
||||||
const [showFollowups, setShowFollowups] = useState(false);
|
const [showFollowups, setShowFollowups] = useState(false);
|
||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||||
@@ -440,9 +450,22 @@ export default function ChatPage() {
|
|||||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||||
: undefined;
|
: undefined;
|
||||||
const isLandingState = isNewThread && !hasStartedConversation;
|
const isLandingState = isNewThread && !hasStartedConversation;
|
||||||
// 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。
|
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||||||
const compactChrome = !isChatsIframeWorkspacePath(pathname);
|
// 判定与 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 compactLanding = isLandingState && compactChrome;
|
||||||
|
const compactActionsHidden = isHideChromeActive();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||||
@@ -454,16 +477,20 @@ export default function ChatPage() {
|
|||||||
compactChrome && "workspace-chat-bg",
|
compactChrome && "workspace-chat-bg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(!isLandingState || compactChrome) && (
|
<header
|
||||||
<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">
|
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">
|
<div className="flex w-full items-center text-sm font-medium">
|
||||||
{!isLandingState && (
|
<ThreadTitle threadId={threadId} thread={thread} />
|
||||||
<ThreadTitle threadId={threadId} thread={thread} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||||
{compactChrome && !historySidebarOpen && (
|
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -487,16 +514,7 @@ export default function ChatPage() {
|
|||||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||||
{compactChrome ? (
|
{!compactChrome && !compactActionsHidden && (
|
||||||
<>
|
|
||||||
<ThemeSwitcher tone="content" />
|
|
||||||
<div
|
|
||||||
className="bg-border/70 mx-1 h-6 w-px shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<UserIdentityBadge variant="topbar" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<CompactShortcutButtons tone="content" />
|
<CompactShortcutButtons tone="content" />
|
||||||
<ThemeSwitcher tone="content" />
|
<ThemeSwitcher tone="content" />
|
||||||
@@ -510,7 +528,6 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
|
||||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||||
<div className="flex size-full justify-center">
|
<div className="flex size-full justify-center">
|
||||||
{/*
|
{/*
|
||||||
@@ -546,7 +563,7 @@ export default function ChatPage() {
|
|||||||
"pointer-events-none absolute z-30 flex justify-center px-4",
|
"pointer-events-none absolute z-30 flex justify-center px-4",
|
||||||
// 紧凑落地态:整块垂直+水平居中于对话区
|
// 紧凑落地态:整块垂直+水平居中于对话区
|
||||||
compactLanding
|
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",
|
: "right-0 bottom-0 left-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -558,7 +575,7 @@ export default function ChatPage() {
|
|||||||
!compactLanding &&
|
!compactLanding &&
|
||||||
"-translate-y-[calc(50vh-96px)]",
|
"-translate-y-[calc(50vh-96px)]",
|
||||||
compactLanding
|
compactLanding
|
||||||
? "max-w-[1024px]"
|
? "max-w-(--container-width-xl)"
|
||||||
: isLandingState
|
: isLandingState
|
||||||
? "max-w-(--container-width-md)"
|
? "max-w-(--container-width-md)"
|
||||||
: "max-w-(--container-width-lg)",
|
: "max-w-(--container-width-lg)",
|
||||||
@@ -577,7 +594,7 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
{isLandingState && mounted &&
|
{isLandingState && mounted &&
|
||||||
(compactLanding ? (
|
(compactLanding ? (
|
||||||
<CompactWelcome className="mb-14" />
|
<CompactWelcome className="mb-6 -translate-y-4" />
|
||||||
) : (
|
) : (
|
||||||
<Welcome
|
<Welcome
|
||||||
mode={settings.context.mode}
|
mode={settings.context.mode}
|
||||||
@@ -585,7 +602,7 @@ export default function ChatPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<div className={cn("w-full", !compactLanding && "-translate-y-4")}>
|
<div className="w-full -translate-y-4">
|
||||||
<InputBox
|
<InputBox
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread={isLandingState}
|
isNewThread={isLandingState}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ImportConversationsDialog } from "@/components/workspace/thread-share-d
|
|||||||
import {
|
import {
|
||||||
CompactShortcutButtons,
|
CompactShortcutButtons,
|
||||||
LayoutModeSwitcher,
|
LayoutModeSwitcher,
|
||||||
|
ThemeSwitcher,
|
||||||
} from "@/components/workspace/appearance-controls";
|
} from "@/components/workspace/appearance-controls";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
@@ -184,6 +185,7 @@ export default function ChatsPage({
|
|||||||
compactChrome ? (
|
compactChrome ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<CompactShortcutButtons tone="content" />
|
<CompactShortcutButtons tone="content" />
|
||||||
|
<ThemeSwitcher tone="content" />
|
||||||
<LayoutModeSwitcher tone="content" />
|
<LayoutModeSwitcher tone="content" />
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
|
BotIcon,
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
ShrinkIcon,
|
ShrinkIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
@@ -25,12 +27,13 @@ import { TodoList } from "@/components/workspace/todo-list";
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
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 { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||||
import { getAccessToken } from "@/core/auth";
|
import { getAccessToken } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
|
import { buildChatsIframeThreadPath } from "@/core/page-layout/chats-iframe-route";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import {
|
import {
|
||||||
isThreadRagModeLocked,
|
isThreadRagModeLocked,
|
||||||
@@ -42,6 +45,7 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
|||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
type BrainyChatSubmitMessage,
|
type BrainyChatSubmitMessage,
|
||||||
@@ -126,6 +130,8 @@ function postBrainyAuthToken(accessToken: string) {
|
|||||||
|
|
||||||
export default function IframeChatPage() {
|
export default function IframeChatPage() {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||||
@@ -141,6 +147,34 @@ export default function IframeChatPage() {
|
|||||||
useState<PendingIframeSubmit | null>(null);
|
useState<PendingIframeSubmit | null>(null);
|
||||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||||
useAgentSelector();
|
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 { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||||
const pendingRequestIdRef = useRef<string | null>(null);
|
const pendingRequestIdRef = useRef<string | null>(null);
|
||||||
const handleSubmitRef = useRef<
|
const handleSubmitRef = useRef<
|
||||||
@@ -230,7 +264,11 @@ export default function IframeChatPage() {
|
|||||||
history.replaceState(
|
history.replaceState(
|
||||||
null,
|
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) {
|
if (pendingRequestIdRef.current) {
|
||||||
@@ -345,6 +383,30 @@ export default function IframeChatPage() {
|
|||||||
saveLastAgentId(agentId);
|
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(() => {
|
useEffect(() => {
|
||||||
handleSubmitRef.current = handleSubmit;
|
handleSubmitRef.current = handleSubmit;
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
@@ -643,15 +705,46 @@ export default function IframeChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLandingState && mounted && (
|
{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="text-2xl font-medium">你好</div>
|
||||||
<div className="mt-4">
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
<AgentSelector
|
选择智能体进入问答
|
||||||
selectedAgentId={selectedAgentId}
|
|
||||||
onAgentChange={handleAgentChange}
|
|
||||||
disabled={isUploading}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
getNodeTitle,
|
getNodeTitle,
|
||||||
} from "@/components/knowledge-graph/graphUtils";
|
} from "@/components/knowledge-graph/graphUtils";
|
||||||
import "@/components/knowledge-graph/graphStyles.css";
|
import "@/components/knowledge-graph/graphStyles.css";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
|
||||||
import {
|
import {
|
||||||
useGraphBlacklist,
|
useGraphBlacklist,
|
||||||
useGraphBlacklistActions,
|
useGraphBlacklistActions,
|
||||||
@@ -799,7 +799,7 @@ export default function KnowledgeGraphPage() {
|
|||||||
searchMatchIds={searchMatchIds}
|
searchMatchIds={searchMatchIds}
|
||||||
layoutType={layoutType}
|
layoutType={layoutType}
|
||||||
showLabels={showLabels}
|
showLabels={showLabels}
|
||||||
theme={theme}
|
theme={themeToBaseMode(theme)}
|
||||||
onSelectNode={handleSelectNode}
|
onSelectNode={handleSelectNode}
|
||||||
onTooltip={setTooltip}
|
onTooltip={setTooltip}
|
||||||
onZoomChange={setZoomLevel}
|
onZoomChange={setZoomLevel}
|
||||||
|
|||||||