Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb09c5991b | ||
|
|
760b88d6f1 |
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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."
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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.
|
||||||
@@ -77,8 +77,14 @@ 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/
|
||||||
hermes-agent-main/
|
# 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/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
deploy
|
deploy
|
||||||
@@ -90,13 +96,12 @@ git-clone/
|
|||||||
# Repo-root offline dependency pack (local only)
|
# Repo-root offline dependency pack (local only)
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
docs/aidev-docs
|
docs/aidev-docs
|
||||||
docs/backups
|
docs/backups
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 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,10 +37,6 @@
|
|||||||
# 默认真实接口(未设/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
|
||||||
@@ -54,6 +50,4 @@
|
|||||||
# 可选
|
# 可选
|
||||||
# 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=
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
**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
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
# 岗位会商交付闭环与内置智能体实现计划
|
|
||||||
|
|
||||||
> 页面:`/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" data-runtime-config></script>
|
<script src="/runtime-config.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -70,7 +70,6 @@
|
|||||||
"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",
|
||||||
@@ -105,7 +104,6 @@
|
|||||||
"@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",
|
||||||
@@ -462,11 +460,37 @@
|
|||||||
"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": "11.1.2",
|
"version": "12.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="
|
||||||
"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",
|
||||||
@@ -1708,12 +1732,11 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
|
||||||
},
|
},
|
||||||
"node_modules/@mermaid-js/parser": {
|
"node_modules/@mermaid-js/parser": {
|
||||||
"version": "1.2.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/types": "~11.1.2"
|
"langium": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/api": {
|
"node_modules/@opentelemetry/api": {
|
||||||
@@ -4251,66 +4274,6 @@
|
|||||||
"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",
|
||||||
@@ -5687,6 +5650,32 @@
|
|||||||
"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",
|
||||||
@@ -6558,16 +6547,6 @@
|
|||||||
"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",
|
||||||
@@ -7375,9 +7354,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/katex": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.47",
|
"version": "0.16.28",
|
||||||
"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"
|
||||||
@@ -7395,6 +7372,23 @@
|
|||||||
"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",
|
||||||
@@ -8106,32 +8100,43 @@
|
|||||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||||
},
|
},
|
||||||
"node_modules/mermaid": {
|
"node_modules/mermaid": {
|
||||||
"version": "11.16.0",
|
"version": "11.14.0",
|
||||||
"resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.1",
|
||||||
"@iconify/utils": "^3.0.2",
|
"@iconify/utils": "^3.0.2",
|
||||||
"@mermaid-js/parser": "^1.2.0",
|
"@mermaid-js/parser": "^1.1.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.3",
|
"cytoscape": "^3.33.1",
|
||||||
"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.20",
|
"dayjs": "^1.11.19",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.1",
|
||||||
"es-toolkit": "^1.45.1",
|
"katex": "^0.16.25",
|
||||||
"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 || ^12 || ^13 || ^14.0.0"
|
"uuid": "^11.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mermaid/node_modules/uuid": {
|
||||||
|
"version": "11.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||||
|
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
@@ -10409,6 +10414,49 @@
|
|||||||
"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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 951 KiB |
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 543 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 966 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -7,30 +7,6 @@
|
|||||||
// 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",
|
||||||
|
|
||||||
@@ -40,10 +16,6 @@ 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",
|
||||||
|
|
||||||
@@ -113,23 +85,12 @@ 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,11 +37,6 @@ 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";
|
||||||
|
|
||||||
@@ -51,7 +46,6 @@ 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(() => {
|
||||||
@@ -60,18 +54,14 @@ 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(
|
const routeTheme = resolveForcedThemeForPath(location.pathname);
|
||||||
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();
|
||||||
@@ -96,7 +86,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", "lightgreen", "iceblue", "lightyellow"]}
|
themes={["light", "dark", "lightblue"]}
|
||||||
enableSystem={false}
|
enableSystem={false}
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
@@ -105,8 +95,8 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
<I18nProvider initialLocale={locale}>
|
<I18nProvider initialLocale={locale}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
<AuthSyncWatcher />
|
||||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
<DesensitizeWordsLoader />
|
||||||
<FontScaleApplier />
|
<FontScaleApplier />
|
||||||
{children}
|
{children}
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
@@ -124,38 +114,17 @@ 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
|
<Route path="/login" element={<LoginPage />} />
|
||||||
path="/login"
|
<Route path="/login/:username" element={<LoginPage />} />
|
||||||
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 />} />
|
||||||
@@ -192,7 +161,7 @@ export function App() {
|
|||||||
path="/roundtable-dashboard"
|
path="/roundtable-dashboard"
|
||||||
element={<DashboardStandalonePage />}
|
element={<DashboardStandalonePage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/page/*" element={pageElement} />
|
<Route path="/page/*" element={<PageRoutes />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 501 B |
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 913 B |
@@ -8,17 +8,6 @@
|
|||||||
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);
|
||||||
@@ -575,37 +564,6 @@
|
|||||||
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;
|
||||||
@@ -629,12 +587,6 @@ html.lightyellow .light-topnav-item--active::after {
|
|||||||
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,15 +17,9 @@ type PageSidebarDarkProps = {
|
|||||||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
/** 全局 Header 隐藏时,侧边栏从视口顶部开始占满高度。 */
|
|
||||||
headerHidden?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PageSidebarDark({
|
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
|
||||||
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();
|
||||||
@@ -202,7 +196,7 @@ export function PageSidebarDark({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 收起时主侧栏不占宽度,保留左下角入口以便随时恢复。
|
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -213,17 +207,13 @@ export function PageSidebarDark({
|
|||||||
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
|
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
|
||||||
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
|
||||||
@@ -254,6 +244,7 @@ export function PageSidebarDark({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="收起侧边栏"
|
title="收起侧边栏"
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ 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";
|
||||||
@@ -81,28 +79,23 @@ const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
|||||||
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
||||||
* (保证嵌入模式下所有层级仍可达,零回归)。
|
* (保证嵌入模式下所有层级仍可达,零回归)。
|
||||||
*/
|
*/
|
||||||
export function PageSidebar({
|
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
|
||||||
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 headerHidden={headerHidden} />;
|
return <SecondaryNavSidebar />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||||||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||||||
*/
|
*/
|
||||||
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
function SecondaryNavSidebar() {
|
||||||
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();
|
||||||
@@ -157,11 +150,7 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
open={notificationOpen}
|
open={notificationOpen}
|
||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<aside
|
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
|
||||||
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="二级菜单">
|
||||||
@@ -298,7 +287,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
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";
|
||||||
@@ -450,9 +438,7 @@ function LegacyAccordionSidebar({
|
|||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
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 ${
|
||||||
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-[var(--button-foreground,var(--primary-foreground))] hover:bg-primary/90",
|
"cursor-pointer bg-primary text-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:
|
||||||
|
|||||||
@@ -71,17 +71,15 @@ const DialogOverlay = React.forwardRef<
|
|||||||
|
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
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) => {
|
||||||
}
|
return (
|
||||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
<DialogPortal data-slot="dialog-portal">
|
||||||
return (
|
<DialogOverlay />
|
||||||
<DialogPortal data-slot="dialog-portal">
|
|
||||||
<DialogOverlay className={overlayClassName} />
|
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
|||||||
@@ -57,21 +57,18 @@ function useSidebarSafe() {
|
|||||||
return React.useContext(SidebarContext);
|
return React.useContext(SidebarContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarProvider({
|
function SidebarProvider({
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
open: openProp,
|
open: openProp,
|
||||||
onOpenChange: setOpenProp,
|
onOpenChange: setOpenProp,
|
||||||
disableToggle = false,
|
className,
|
||||||
className,
|
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
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);
|
||||||
@@ -95,11 +92,10 @@ function SidebarProvider({
|
|||||||
[setOpenProp, open],
|
[setOpenProp, open],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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(() => {
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
"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,340 +111,6 @@
|
|||||||
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,60 +24,12 @@ const AGENT_COLORS_DARK = [
|
|||||||
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
|
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 按 agent id 稳定散列出一对渐变色,并根据当前主题返回可读的图标前景色。 */
|
/** 按 agent id 稳定散列出一对渐变色(from → to)。`isDark` 切换深蓝/浅色调色板。 */
|
||||||
const AGENT_COLORS_ICEBLUE = [
|
export function agentColor(id: string, isDark: boolean) {
|
||||||
{ 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 =
|
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
|
||||||
theme === "dark"
|
return palette[hash % palette.length]!;
|
||||||
? 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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"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,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -38,14 +38,21 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
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,
|
||||||
@@ -59,7 +66,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
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";
|
||||||
@@ -548,10 +556,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
内置
|
内置
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isOwned && (
|
{isOwned && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
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"
|
||||||
>
|
>
|
||||||
我的
|
我的
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
{isFromOthers && (
|
{isFromOthers && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
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"
|
||||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||||
>
|
>
|
||||||
来自他人
|
来自他人
|
||||||
@@ -568,7 +576,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-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
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"
|
||||||
title="该智能体已对所有用户可见"
|
title="该智能体已对所有用户可见"
|
||||||
>
|
>
|
||||||
<GlobeIcon className="size-3" />
|
<GlobeIcon className="size-3" />
|
||||||
@@ -663,8 +671,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
const skillBadgeItems = [
|
const skillBadgeItems = [
|
||||||
...(agent.tool_groups ?? []).map((group) => ({
|
...(agent.tool_groups ?? []).map((group) => ({
|
||||||
key: `tg:${group}`,
|
key: `tg:${group}`,
|
||||||
node: (
|
node: (
|
||||||
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
<Badge
|
||||||
|
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>
|
||||||
),
|
),
|
||||||
@@ -674,8 +686,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
node: (
|
node: (
|
||||||
<Badge
|
<Badge
|
||||||
key={`sk:${skill}`}
|
key={`sk:${skill}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
className="cursor-pointer border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
|
||||||
title={t.settings.skills.viewDetailAction}
|
title={t.settings.skills.viewDetailAction}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -823,80 +835,181 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const chatButton = (
|
const chatButton = (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
variant={isList ? "default" : "outline"}
|
||||||
className={cn(isList ? "shrink-0" : "w-full")}
|
size="sm"
|
||||||
onClick={handleChat}
|
className={cn(
|
||||||
|
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}
|
||||||
>
|
>
|
||||||
<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 className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
<Card
|
||||||
<CardHeader className="min-w-0 pb-2">
|
className={cn(
|
||||||
<div className="flex min-w-0 items-start justify-between gap-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 flex-1 items-center gap-2 overflow-hidden">
|
agent.featured_order != null && "border-blue-300 ring-1 ring-blue-100 dark:border-blue-700 dark:ring-blue-900/40",
|
||||||
<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>
|
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
{agent.featured_order != null ? (
|
||||||
<CardTitle
|
<>
|
||||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
<div className="mb-1 flex h-7 items-center justify-between">
|
||||||
title={desensitize(agent.name)}
|
<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">
|
||||||
>
|
<SparklesIcon className="mr-1 size-3.5 fill-current" />
|
||||||
{desensitize(agent.name)}
|
推荐
|
||||||
</CardTitle>
|
</Badge>
|
||||||
{agent.model && (
|
{cardActionsMenu}
|
||||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
</div>
|
||||||
{desensitize(agent.model)}
|
<AgentBrandIcon size="lg" />
|
||||||
</Badge>
|
</>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="flex items-start justify-between">
|
||||||
</div>
|
<AgentBrandIcon size="lg" />
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
{cardActionsMenu}
|
||||||
{statusBadges}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<CardTitle
|
||||||
{agent.description && (
|
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"
|
||||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
title={desensitize(agent.name)}
|
||||||
{desensitize(agent.description)}
|
>
|
||||||
</CardDescription>
|
{desensitize(agent.name)}
|
||||||
)}
|
</CardTitle>
|
||||||
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
{agent.description && (
|
||||||
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
|
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
|
||||||
</CardHeader>
|
{desensitize(agent.description)}
|
||||||
|
</CardDescription>
|
||||||
{(agent.tags?.length ?? 0) > 0 && (
|
)}
|
||||||
<CardContent className="pt-0 pb-2">
|
</CardHeader>
|
||||||
<TagChipList tags={agent.tags!} />
|
|
||||||
|
{(agent.tags?.length ?? 0) > 0 && (
|
||||||
|
<CardContent className="px-5 pt-0 pb-1.5">
|
||||||
|
<TagChipList tags={agent.tags!} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSkillsOrTools && (
|
{hasSkillsOrTools && (
|
||||||
<CardContent className="pt-0 pb-2">
|
<CardContent className="px-5 pt-0 pb-2">
|
||||||
<ClampedBadgeRow items={skillBadgeItems} />
|
<ClampedBadgeRow items={skillBadgeItems} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
<CardFooter className="mt-auto flex flex-col items-stretch gap-10 px-5 pt-2 pb-4">
|
||||||
{showActionRow && (
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
{cardStatusBadge}
|
||||||
{actionButtons}
|
<span
|
||||||
</div>
|
className="min-w-0 truncate text-xs text-slate-400"
|
||||||
)}
|
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
|
||||||
{chatButton}
|
>
|
||||||
</CardFooter>
|
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
|
||||||
|
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{chatButton}
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const listLayout = (
|
const listLayout = (
|
||||||
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
<div className="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="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
<AgentBrandIcon className="agent-avatar" size="md" />
|
||||||
<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, foreground } = agentColor(agent.id, theme);
|
const { from, to } = agentColor(agent.id, isDark);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
@@ -279,7 +279,6 @@ 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,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BotIcon,
|
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
@@ -43,6 +42,7 @@ 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,9 +520,7 @@ 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">
|
||||||
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
<AgentBrandIcon className="agent-avatar" size="lg" />
|
||||||
<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,6 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
import {
|
||||||
|
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";
|
||||||
@@ -10,17 +19,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 { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||||
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, useViewMode } from "@/core/system-settings/hooks";
|
import { usePrivateSquareStatus, usePublishSquares } 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
|
||||||
@@ -32,11 +41,10 @@ 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).
|
||||||
@@ -53,10 +61,9 @@ 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, tagKey, sort, publisher?.id]);
|
}, [search, sort, publisher?.id, statusFilter]);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -108,7 +115,6 @@ export function AgentGallery() {
|
|||||||
} = useAgentsPaginated({
|
} = useAgentsPaginated({
|
||||||
scope: mainScope,
|
scope: mainScope,
|
||||||
search,
|
search,
|
||||||
tagIds,
|
|
||||||
sort,
|
sort,
|
||||||
page: mainPage,
|
page: mainPage,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
@@ -126,7 +132,6 @@ 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,
|
||||||
@@ -177,21 +182,22 @@ export function AgentGallery() {
|
|||||||
const handlePublisherClick = (p: { id: string; label: string }) =>
|
const handlePublisherClick = (p: { id: string; label: string }) =>
|
||||||
setPublisher(p);
|
setPublisher(p);
|
||||||
|
|
||||||
const renderGrid = (list: Agent[]) =>
|
const filterByStatus = (agent: Agent) => {
|
||||||
viewMode === "list" ? (
|
if (statusFilter === "published") return agent.published && !agent.is_private;
|
||||||
<div className="flex flex-col gap-2">
|
if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin;
|
||||||
{list.map((agent) => (
|
if (statusFilter === "private") return agent.is_private;
|
||||||
<AgentCard
|
if (statusFilter === "builtin") return agent.builtin;
|
||||||
key={agent.id}
|
return true;
|
||||||
agent={agent}
|
};
|
||||||
layout="list"
|
|
||||||
onPublisherClick={handlePublisherClick}
|
const renderGrid = (list: Agent[]) => {
|
||||||
/>
|
const visibleAgents = list.filter(filterByStatus);
|
||||||
))}
|
if (visibleAgents.length === 0 && list.length > 0) {
|
||||||
</div>
|
return renderEmpty("当前状态筛选下没有智能体。");
|
||||||
) : (
|
}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
return (
|
||||||
{list.map((agent) => (
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{visibleAgents.map((agent) => (
|
||||||
<AgentCard
|
<AgentCard
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
@@ -200,6 +206,7 @@ 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: {
|
||||||
@@ -264,10 +271,8 @@ export function AgentGallery() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderEmpty = (description: string, showNewButton = false) => (
|
const renderEmpty = (description: string, showNewButton = false) => (
|
||||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
<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="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
|
<AgentBrandIcon size="lg" />
|
||||||
<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]">
|
||||||
@@ -276,8 +281,7 @@ export function AgentGallery() {
|
|||||||
</div>
|
</div>
|
||||||
{showNewButton && (
|
{showNewButton && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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"
|
||||||
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" />
|
||||||
@@ -364,17 +368,22 @@ export function AgentGallery() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full min-h-0 flex-col">
|
<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">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex shrink-0 items-start justify-between px-[38px] pt-[45px] pb-[38px]">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
<h1 className="text-[28px] leading-none font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
|
||||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
{t.agents.title}
|
||||||
{t.agents.description}
|
</h1>
|
||||||
|
<p className="mt-4 text-[15px] text-slate-500 dark:text-slate-400">
|
||||||
|
创建并管理专属的 AI 智能体,助力更高效的工作与决策。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleNewAgent}>
|
<Button
|
||||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
onClick={handleNewAgent}
|
||||||
|
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>
|
||||||
@@ -384,43 +393,99 @@ export function AgentGallery() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setActiveTab(v);
|
setActiveTab(v);
|
||||||
// Each tab starts from a clean slate: switching tabs clears the
|
// Each tab starts from a clean slate instead of carrying filters over.
|
||||||
// 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="mx-6 mt-4 flex flex-col gap-3">
|
<div className="shrink-0 px-[38px]">
|
||||||
<div className="flex items-center justify-between 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">
|
||||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
<TabsList variant="line" className="h-auto w-auto max-w-full flex-wrap justify-start self-start rounded-none border-0 bg-transparent p-0">
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<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 key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
<TabsTrigger
|
||||||
|
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 value="private">
|
<TabsTrigger
|
||||||
|
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 value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
<TabsTrigger
|
||||||
|
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>
|
||||||
<div className="flex items-center gap-3">
|
{showSearchBar && (
|
||||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
<div className="flex flex-wrap items-center justify-end gap-5 pb-2.5">
|
||||||
共 {activeTotal} 个
|
<div className="relative w-[350px] max-w-full">
|
||||||
</span>
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-3.5 size-[18px] -translate-y-1/2 text-slate-500" />
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
<Input
|
||||||
|
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"
|
||||||
@@ -432,48 +497,16 @@ 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="flex items-center gap-2">
|
<div className="mt-3 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="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"
|
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"
|
||||||
title="点击清除发布者筛选"
|
title="点击清除发布者筛选"
|
||||||
>
|
>
|
||||||
{publisher.label}
|
{publisher.label}
|
||||||
@@ -481,13 +514,16 @@ 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="flex-1 overflow-y-auto p-6 mt-0"
|
className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
|
||||||
>
|
>
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
@@ -500,7 +536,7 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
<TabsContent value="mine" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
total: mainTotal,
|
total: mainTotal,
|
||||||
@@ -513,7 +549,7 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
<TabsContent value="builtin" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
total: mainTotal,
|
total: mainTotal,
|
||||||
@@ -528,7 +564,7 @@ export function AgentGallery() {
|
|||||||
{privateFeatureAvailable && (
|
{privateFeatureAvailable && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="private"
|
value="private"
|
||||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
|
||||||
>
|
>
|
||||||
{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, foreground } = agentColor(agent.id, theme);
|
const { from, to } = agentColor(agent.id, isDark);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={`${keyPrefix}:${agent.id}`}
|
key={`${keyPrefix}:${agent.id}`}
|
||||||
@@ -236,10 +236,7 @@ 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={{
|
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
|
||||||
color: foreground,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Bot className="size-[19px]" />
|
<Bot className="size-[19px]" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -93,47 +93,43 @@ export function AgentSelector({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
{iconMode ? (
|
<DialogTrigger asChild>
|
||||||
<Tooltip content={iconTooltipLabel}>
|
{iconMode ? (
|
||||||
<div>
|
<Tooltip content={iconTooltipLabel}>
|
||||||
<DialogTrigger asChild>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
disabled={disabled}
|
||||||
disabled={disabled}
|
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
className={cn(
|
||||||
className={cn(
|
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
selectedAgent && iconActiveClassName,
|
||||||
selectedAgent && iconActiveClassName,
|
iconButtonClassName,
|
||||||
iconButtonClassName,
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<BotIcon className="size-4" />
|
||||||
<BotIcon className="size-4" />
|
{selectedAgent && (
|
||||||
{selectedAgent && (
|
<span
|
||||||
<span
|
role="button"
|
||||||
role="button"
|
tabIndex={0}
|
||||||
tabIndex={0}
|
aria-label="清除已选 agent"
|
||||||
aria-label="清除已选 agent"
|
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
onClick={handleClear}
|
||||||
onClick={handleClear}
|
onKeyDown={(e) => {
|
||||||
onKeyDown={(e) => {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
onAgentChange(null);
|
||||||
onAgentChange(null);
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<XIcon className="size-2.5" />
|
||||||
<XIcon className="size-2.5" />
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</DialogTrigger>
|
) : (
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -171,8 +167,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,32 +45,11 @@ function skillLabel(skill: Skill) {
|
|||||||
|
|
||||||
function matchesQuery(skill: Skill, q: string) {
|
function matchesQuery(skill: Skill, q: string) {
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const haystack = [
|
return (
|
||||||
skill.name,
|
skill.name.toLowerCase().includes(q) ||
|
||||||
skill.name_zh,
|
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||||
skill.description,
|
(skill.description ?? "").toLowerCase().includes(q)
|
||||||
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({
|
||||||
@@ -180,7 +159,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);
|
||||||
@@ -260,7 +239,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"
|
||||||
@@ -269,10 +248,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"
|
||||||
@@ -281,7 +260,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, useMemo, useRef, useState } from "react";
|
import { useEffect, 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,8 +15,6 @@ 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";
|
||||||
|
|
||||||
@@ -28,13 +26,10 @@ 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),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
|
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -84,23 +79,8 @@ 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) {
|
||||||
@@ -115,8 +95,6 @@ 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"
|
||||||
@@ -139,9 +117,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 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="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
|
||||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-border bg-popover" />
|
<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" />
|
||||||
{swatches.map((swatch) => {
|
{THEME_SWATCHES.map((swatch) => {
|
||||||
const active = theme === swatch.id;
|
const active = theme === swatch.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -207,8 +185,6 @@ 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>
|
||||||
|
|||||||
@@ -123,14 +123,13 @@ export function ArtifactFileDetail({
|
|||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
threadId,
|
threadId,
|
||||||
onClose,
|
onClose,
|
||||||
allowVirtualMarkdownEdit = false,
|
allowVirtualMarkdownEdit = false,
|
||||||
disableDesensitize = false,
|
disableDesensitize = false,
|
||||||
hideFileName = false,
|
}: {
|
||||||
}: {
|
className?: string;
|
||||||
className?: string;
|
filepath: string;
|
||||||
filepath: string;
|
threadId: string;
|
||||||
threadId: string;
|
|
||||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -143,10 +142,9 @@ export function ArtifactFileDetail({
|
|||||||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||||||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||||||
* 默认 false,沿用全局脱敏。
|
* 默认 false,沿用全局脱敏。
|
||||||
*/
|
*/
|
||||||
disableDesensitize?: boolean;
|
disableDesensitize?: boolean;
|
||||||
hideFileName?: boolean;
|
}) {
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
const { isMock, thread, publicShareCode } = useThread();
|
||||||
@@ -224,11 +222,10 @@ export function ArtifactFileDetail({
|
|||||||
const canUseAiEditor = canEditArtifact;
|
const canUseAiEditor = canEditArtifact;
|
||||||
const [draftContent, setDraftContent] = useState("");
|
const [draftContent, setDraftContent] = useState("");
|
||||||
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 isAutoPreviewLanguage = language === "html" || language === "markdown";
|
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||||
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";
|
||||||
if (isSpreadsheetFile) return "XLSX";
|
if (isSpreadsheetFile) return "XLSX";
|
||||||
@@ -267,36 +264,29 @@ export function ArtifactFileDetail({
|
|||||||
setDraftContent(content ?? "");
|
setDraftContent(content ?? "");
|
||||||
}, [canEditArtifact, content, hasLocalEdits]);
|
}, [canEditArtifact, content, hasLocalEdits]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAllowAutoViewSwitch(true);
|
setAllowAutoViewSwitch(true);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
setShowResumeAutoScroll(false);
|
setShowResumeAutoScroll(false);
|
||||||
setShowPreviewSwitchPulse(false);
|
|
||||||
}, [filepathFromProps]);
|
if (language === "html") {
|
||||||
|
setViewMode(thread.isLoading ? "code" : "preview");
|
||||||
useEffect(() => {
|
return;
|
||||||
if (isPreviewStreaming) {
|
}
|
||||||
setViewMode("code");
|
|
||||||
return;
|
// md/mermaid 默认预览(AI 写作视图作为可选切换项,由用户手动进入)。
|
||||||
}
|
if (isSupportPreview) {
|
||||||
|
setViewMode("preview");
|
||||||
if (language === "html") {
|
return;
|
||||||
setViewMode(thread.isLoading ? "code" : "preview");
|
}
|
||||||
return;
|
|
||||||
}
|
setViewMode("code");
|
||||||
|
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]);
|
||||||
if (isSupportPreview) {
|
|
||||||
setViewMode("preview");
|
useEffect(() => {
|
||||||
return;
|
if (!allowAutoViewSwitch || language !== "html") {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
setViewMode("code");
|
|
||||||
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thread.isLoading) {
|
if (thread.isLoading) {
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
@@ -308,9 +298,9 @@ export function ArtifactFileDetail({
|
|||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
setShowPreviewSwitchPulse(false);
|
setShowPreviewSwitchPulse(false);
|
||||||
}, 1400);
|
}, 1400);
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
|
}, [allowAutoViewSwitch, language, thread.isLoading]);
|
||||||
|
|
||||||
const scrollArtifactToBottom = useCallback(
|
const scrollArtifactToBottom = useCallback(
|
||||||
(behavior: ScrollBehavior = "auto") => {
|
(behavior: ScrollBehavior = "auto") => {
|
||||||
@@ -593,8 +583,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 徽标),把横向空间让给工具栏,避免窄面板下
|
||||||
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
||||||
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
|
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
|
||||||
@@ -617,8 +606,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">
|
||||||
@@ -664,10 +652,10 @@ export function ArtifactFileDetail({
|
|||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
{isPreviewStreaming && (
|
{isHtmlStreaming && (
|
||||||
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
|
<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" />
|
||||||
正在生成,完成后自动预览
|
正在生成,完成后自动预览
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,34 +23,28 @@ import { useThread } from "../messages/context";
|
|||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||||
|
|
||||||
export function ArtifactFileList({
|
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();
|
||||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||||
const [installFile, setInstallFile] = useState<string | null>(null);
|
const [installFile, setInstallFile] = useState<string | null>(null);
|
||||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(filepath: string) => {
|
(filepath: string) => {
|
||||||
if (onOpenArtifact) {
|
selectArtifact(filepath);
|
||||||
onOpenArtifact(filepath);
|
setOpen(true);
|
||||||
return;
|
},
|
||||||
}
|
[selectArtifact, setOpen],
|
||||||
selectArtifact(filepath);
|
);
|
||||||
setOpen(true);
|
|
||||||
},
|
|
||||||
[onOpenArtifact, selectArtifact, setOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInstallSkill = useCallback(
|
const handleInstallSkill = useCallback(
|
||||||
(e: React.MouseEvent, filepath: string) => {
|
(e: React.MouseEvent, filepath: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"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,12 +1,6 @@
|
|||||||
"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 参考图重画。
|
||||||
@@ -14,63 +8,18 @@ import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desen
|
|||||||
* 非紧凑模式仍用 <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 select-none",
|
"mx-auto flex w-full flex-col items-center justify-center text-center",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
|
<h1 className="flex items-center justify-center text-[34px] font-semibold tracking-[-0.035em] text-slate-950 dark:text-slate-50">
|
||||||
{/* Controlled by VITE_WELCOME_LOGO_VISIBLE in runtime-config.js. */}
|
你好,欢迎使用 CM 助手
|
||||||
{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="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
<p className="mx-auto mt-2.5 max-w-xl text-[15px] leading-6 text-slate-500 dark:text-slate-400">
|
||||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
我可以帮你写作、问答、分析和规划各类内容,让工作更高效
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ 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(
|
||||||
@@ -81,7 +83,13 @@ export function CustomPromptBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
<div
|
||||||
|
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 && (
|
||||||
@@ -111,6 +119,7 @@ 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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -139,7 +148,11 @@ export function CustomPromptBar({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
className={cn(
|
||||||
|
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
|
||||||
|
compact &&
|
||||||
|
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-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(
|
||||||
"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",
|
"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, foreground } = agentColor(agent.id, theme);
|
const { from, to } = agentColor(agent.id, isDark);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
@@ -366,10 +366,7 @@ 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={{
|
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
|
||||||
color: foreground,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Bot className="size-[19px]" />
|
<Bot className="size-[19px]" />
|
||||||
</span>
|
</span>
|
||||||
@@ -467,9 +464,9 @@ export function GeneralQaSidebar() {
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
? "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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
BrainCircuitIcon,
|
BarChart3Icon,
|
||||||
BookMarkedIcon,
|
BrainCircuitIcon,
|
||||||
CheckIcon,
|
BookMarkedIcon,
|
||||||
FileTextIcon,
|
CheckIcon,
|
||||||
GraduationCapIcon,
|
ClipboardCheckIcon,
|
||||||
LightbulbIcon,
|
FileTextIcon,
|
||||||
PaperclipIcon,
|
GraduationCapIcon,
|
||||||
PlusIcon,
|
LightbulbIcon,
|
||||||
ShrinkIcon,
|
MessageCircleQuestionIcon,
|
||||||
|
PaperclipIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
ShrinkIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
@@ -102,7 +106,58 @@ import { useThread } from "./messages/context";
|
|||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
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(
|
||||||
@@ -210,17 +265,41 @@ export function InputBox({
|
|||||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||||
const userPreferences = useUserPreferences();
|
const userPreferences = useUserPreferences();
|
||||||
const showAdminRecommendedQuestions =
|
const showAdminRecommendedQuestions =
|
||||||
!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) {
|
||||||
@@ -252,10 +331,11 @@ export function InputBox({
|
|||||||
|
|
||||||
const resolvedModelName = selectedModel?.name;
|
const resolvedModelName = selectedModel?.name;
|
||||||
|
|
||||||
const supportThinking = useMemo(
|
const supportThinking = useMemo(
|
||||||
() => 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,
|
||||||
@@ -529,14 +609,68 @@ export function InputBox({
|
|||||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
<div
|
||||||
|
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" && showAdminRecommendedQuestions && (
|
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
|
||||||
|
<section className="px-2" aria-labelledby="recommended-prompts-title">
|
||||||
|
<div className="mb-3 flex items-center justify-center gap-1.5">
|
||||||
|
<h2
|
||||||
|
id="recommended-prompts-title"
|
||||||
|
className="text-[14px] font-normal text-slate-400 dark:text-slate-500"
|
||||||
|
>
|
||||||
|
你可以这样问我
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-prompt-order-refresh="true"
|
||||||
|
title="调整提示词顺序"
|
||||||
|
aria-label="调整提示词顺序"
|
||||||
|
onClick={() => setCompactPromptOrder((current) => current + 1)}
|
||||||
|
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-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
|
||||||
@@ -587,9 +721,10 @@ export function InputBox({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{isNewThread && (
|
{isNewThread && (
|
||||||
<CustomPromptBar
|
<CustomPromptBar
|
||||||
className="px-1"
|
compact={compact}
|
||||||
|
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() ?? "";
|
||||||
@@ -612,8 +747,8 @@ export function InputBox({
|
|||||||
? "*:data-[slot='input-group']:rounded-2xl *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:bg-transparent *:data-[slot='input-group']:shadow-none"
|
? "*:data-[slot='input-group']:rounded-2xl *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:bg-transparent *:data-[slot='input-group']:shadow-none"
|
||||||
: "*:data-[slot='input-group']:rounded-3xl",
|
: "*:data-[slot='input-group']:rounded-3xl",
|
||||||
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
||||||
compact && isNewThread
|
compact && isNewThread
|
||||||
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
|
? "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"
|
||||||
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||||
isNewThread
|
isNewThread
|
||||||
@@ -637,7 +772,8 @@ 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 && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
compact &&
|
||||||
|
"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}
|
||||||
@@ -648,7 +784,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-3 pt-1.5 pb-3",
|
compact && "gap-2 px-6 pt-2 pb-5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PromptInputTools
|
<PromptInputTools
|
||||||
@@ -783,52 +919,47 @@ export function InputBox({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
context.mode === "flash" ||
|
effectiveMode
|
||||||
context.mode === "thinking" ||
|
}
|
||||||
context.mode === "pro" ||
|
|
||||||
context.mode === "ultra"
|
|
||||||
? context.mode
|
|
||||||
: "flash"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<PromptInputActionMenuTrigger
|
<PromptInputActionMenuTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
"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 &&
|
||||||
(context.mode === "thinking"
|
(effectiveMode === "thinking"
|
||||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||||
: context.mode === "pro"
|
: effectiveMode === "pro"
|
||||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||||
: context.mode === "ultra"
|
: effectiveMode === "ultra"
|
||||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
? "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>
|
||||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
{effectiveMode === "flash" && <ZapIcon className="size-3" />}
|
||||||
{context.mode === "thinking" && (
|
{effectiveMode === "thinking" && (
|
||||||
<LightbulbIcon className="size-3" />
|
<LightbulbIcon className="size-3" />
|
||||||
)}
|
)}
|
||||||
{context.mode === "pro" && (
|
{effectiveMode === "pro" && (
|
||||||
<GraduationCapIcon className="size-3" />
|
<GraduationCapIcon className="size-3" />
|
||||||
)}
|
)}
|
||||||
{context.mode === "ultra" && (
|
{effectiveMode === "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",
|
||||||
context.mode === "ultra" ? "golden-text" : "",
|
effectiveMode === "ultra" ? "golden-text" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
{(effectiveMode === "flash" && t.inputBox.flashMode) ||
|
||||||
(context.mode === "thinking" &&
|
(effectiveMode === "thinking" &&
|
||||||
t.inputBox.reasoningMode) ||
|
t.inputBox.reasoningMode) ||
|
||||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
(effectiveMode === "pro" && t.inputBox.proMode) ||
|
||||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
(effectiveMode === "ultra" && t.inputBox.ultraMode)}
|
||||||
</div>
|
</div>
|
||||||
</PromptInputActionMenuTrigger>
|
</PromptInputActionMenuTrigger>
|
||||||
</ModeHoverGuide>
|
</ModeHoverGuide>
|
||||||
@@ -1124,8 +1255,10 @@ export function InputBox({
|
|||||||
onOpenChange={setModelDialogOpen}
|
onOpenChange={setModelDialogOpen}
|
||||||
>
|
>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn(
|
disabled={models.length === 0}
|
||||||
|
aria-label="模型选择"
|
||||||
|
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 &&
|
||||||
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||||
@@ -1138,7 +1271,7 @@ export function InputBox({
|
|||||||
compact && "text-[11px]",
|
compact && "text-[11px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedModel?.display_name}
|
{selectedModel?.display_name || "默认模型"}
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
</div>
|
</div>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
@@ -1171,8 +1304,8 @@ export function InputBox({
|
|||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
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-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",
|
"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",
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ 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 { SourceCard } from "../skill-display-sources";
|
import {
|
||||||
|
isWeKnoraSourcePreviewUrl,
|
||||||
|
SourceCard,
|
||||||
|
} from "../skill-display-sources";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
|
||||||
@@ -639,19 +642,43 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const content = item.snippet ?? item.content;
|
const content = item.snippet ?? item.content;
|
||||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||||
let sourceLabel: string | undefined = item.source;
|
let sourceLabel: string | undefined = item.source;
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
try {
|
try {
|
||||||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||||
} catch {
|
} catch {
|
||||||
sourceLabel = sourceLabel ?? item.url;
|
sourceLabel = sourceLabel ?? item.url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
|
// 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 (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -135,12 +135,11 @@ export function MessageListItem({
|
|||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onMessageDeleted,
|
||||||
onMessageDeleted,
|
turnTime,
|
||||||
turnTime,
|
}: {
|
||||||
}: {
|
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -152,11 +151,10 @@ export function MessageListItem({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
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
|
||||||
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
||||||
@@ -164,102 +162,14 @@ export function MessageListItem({
|
|||||||
* wall clock at render time and appear to "drift" to now on every reload.
|
* wall clock at render time and appear to "drift" to now on every reload.
|
||||||
*/
|
*/
|
||||||
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) ??
|
'';
|
||||||
'';
|
return (
|
||||||
const [assistantEditing, setAssistantEditing] = useState(false);
|
<AIElementMessage
|
||||||
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
|
|
||||||
const [assistantSaving, setAssistantSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!assistantEditing) {
|
|
||||||
setAssistantDraft(messageMarkdown);
|
|
||||||
}
|
|
||||||
}, [assistantEditing, messageMarkdown]);
|
|
||||||
|
|
||||||
const canSaveAssistantEdit =
|
|
||||||
!isLoading &&
|
|
||||||
!isHuman &&
|
|
||||||
!!onEditSave &&
|
|
||||||
!!message.id &&
|
|
||||||
!message.id.startsWith('opt-') &&
|
|
||||||
!!messageMarkdown;
|
|
||||||
const confirmAssistantEdit = useCallback(async () => {
|
|
||||||
const value = assistantDraft.trim();
|
|
||||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAssistantSaving(true);
|
|
||||||
try {
|
|
||||||
await onEditSave(message.id, value);
|
|
||||||
setAssistantEditing(false);
|
|
||||||
} catch {
|
|
||||||
// The page-level save callback owns user-facing error toasts. Keep the
|
|
||||||
// editor open so the user can adjust and retry.
|
|
||||||
} finally {
|
|
||||||
setAssistantSaving(false);
|
|
||||||
}
|
|
||||||
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
|
|
||||||
|
|
||||||
if (!isHuman && assistantEditing) {
|
|
||||||
return (
|
|
||||||
<AIElementMessage
|
|
||||||
id={anchorId}
|
|
||||||
className={cn(
|
|
||||||
'group/conversation-message relative',
|
|
||||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
|
||||||
anchorId && 'scroll-mt-20',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
from="assistant"
|
|
||||||
>
|
|
||||||
<AIElementMessageContent className="w-full">
|
|
||||||
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
|
|
||||||
<Textarea
|
|
||||||
value={assistantDraft}
|
|
||||||
autoFocus
|
|
||||||
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
||||||
event.preventDefault();
|
|
||||||
void confirmAssistantEdit();
|
|
||||||
}
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
setAssistantEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="min-h-32 resize-y text-[15px] leading-7"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={assistantSaving}
|
|
||||||
onClick={() => setAssistantEditing(false)}
|
|
||||||
>
|
|
||||||
{t.common.cancel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={!assistantDraft.trim() || assistantSaving}
|
|
||||||
onClick={() => void confirmAssistantEdit()}
|
|
||||||
>
|
|
||||||
{assistantSaving ? "保存中..." : t.common.save}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AIElementMessageContent>
|
|
||||||
</AIElementMessage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AIElementMessage
|
|
||||||
id={anchorId}
|
id={anchorId}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/conversation-message relative',
|
'group/conversation-message relative',
|
||||||
@@ -277,12 +187,11 @@ export function MessageListItem({
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onEditSave={isLoading ? undefined : onEditSave}
|
onMessageDeleted={onMessageDeleted}
|
||||||
onMessageDeleted={onMessageDeleted}
|
turnTime={turnTime}
|
||||||
turnTime={turnTime}
|
/>
|
||||||
/>
|
|
||||||
{/*
|
{/*
|
||||||
Human messages carry their own inline copy/edit toolbar inside
|
Human messages carry their own inline copy/edit toolbar inside
|
||||||
MessageContent (above the timestamp). Only assistant answers use the
|
MessageContent (above the timestamp). Only assistant answers use the
|
||||||
@@ -297,28 +206,15 @@ export function MessageListItem({
|
|||||||
>
|
>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<CopyButton clipboardData={messageMarkdown} />
|
<CopyButton clipboardData={messageMarkdown} />
|
||||||
{messageMarkdown && (
|
{messageMarkdown && (
|
||||||
<DownloadWordButton
|
<DownloadWordButton
|
||||||
markdown={messageMarkdown}
|
markdown={messageMarkdown}
|
||||||
fileName="message"
|
fileName="message"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canSaveAssistantEdit && (
|
</div>
|
||||||
<Tooltip content="编辑消息">
|
</MessageToolbar>
|
||||||
<Button
|
)}
|
||||||
size="icon-sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="size-6"
|
|
||||||
onClick={() => setAssistantEditing(true)}
|
|
||||||
>
|
|
||||||
<PencilIcon size={11} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</MessageToolbar>
|
|
||||||
)}
|
|
||||||
</AIElementMessage>
|
</AIElementMessage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -332,31 +228,26 @@ export function MessageListItem({
|
|||||||
* the question; confirming sends the edited text as a brand-new turn (the
|
* the question; confirming sends the edited text as a brand-new turn (the
|
||||||
* 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,
|
deleteButton,
|
||||||
onEditSave,
|
}: {
|
||||||
deleteButton,
|
text: string;
|
||||||
}: {
|
pageContext?: EmbeddedPageContext | null;
|
||||||
messageId?: string;
|
filesList: React.ReactNode;
|
||||||
text: string;
|
messageTime: { label: string; full: string } | null;
|
||||||
pageContext?: EmbeddedPageContext | null;
|
components: MarkdownContentProps["components"];
|
||||||
filesList: React.ReactNode;
|
onEditResubmit?: (text: string) => void;
|
||||||
messageTime: { label: string; full: string } | null;
|
deleteButton?: React.ReactNode;
|
||||||
components: MarkdownContentProps["components"];
|
}) {
|
||||||
onEditResubmit?: (text: string) => void;
|
const { t } = useI18n();
|
||||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
const [editing, setEditing] = useState(false);
|
||||||
deleteButton?: React.ReactNode;
|
const [draft, setDraft] = useState(text);
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [draft, setDraft] = useState(text);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
@@ -364,27 +255,14 @@ function HumanMessageBlock({
|
|||||||
}
|
}
|
||||||
}, [text, editing]);
|
}, [text, editing]);
|
||||||
|
|
||||||
const confirmEdit = useCallback(async () => {
|
const confirmEdit = useCallback(() => {
|
||||||
const value = draft.trim();
|
const value = draft.trim();
|
||||||
if (!value || saving) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
|
onEditResubmit?.(value);
|
||||||
setSaving(true);
|
setEditing(false);
|
||||||
try {
|
}, [draft, onEditResubmit]);
|
||||||
await onEditSave(messageId, value);
|
|
||||||
setEditing(false);
|
|
||||||
} catch {
|
|
||||||
// The page-level save callback owns user-facing error toasts. Keep the
|
|
||||||
// editor open so the user can adjust and retry.
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onEditResubmit?.(value);
|
|
||||||
setEditing(false);
|
|
||||||
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
@@ -397,10 +275,10 @@ 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();
|
||||||
void confirmEdit();
|
confirmEdit();
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -408,22 +286,17 @@ function HumanMessageBlock({
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<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"
|
</Button>
|
||||||
onClick={() => void confirmEdit()}
|
</div>
|
||||||
disabled={!draft.trim() || saving}
|
</div>
|
||||||
>
|
|
||||||
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,11 +324,11 @@ 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 || onEditSave) && (
|
{text && onEditResubmit && (
|
||||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
<Tooltip content={t.inputBox.editQuestion}>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-6"
|
className="size-6"
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
@@ -523,24 +396,22 @@ function MessageContent_({
|
|||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onMessageDeleted,
|
||||||
onMessageDeleted,
|
turnTime,
|
||||||
turnTime,
|
}: {
|
||||||
}: {
|
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
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;
|
}) {
|
||||||
}) {
|
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
|
|
||||||
// Streamdown splits content into per-block processors; calling the factory
|
// Streamdown splits content into per-block processors; calling the factory
|
||||||
@@ -684,16 +555,14 @@ function MessageContent_({
|
|||||||
const canDelete =
|
const canDelete =
|
||||||
!!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}
|
deleteButton={
|
||||||
onEditSave={onEditSave}
|
|
||||||
deleteButton={
|
|
||||||
canDelete ? (
|
canDelete ? (
|
||||||
<DeleteMessageButton
|
<DeleteMessageButton
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
|||||||
@@ -97,18 +97,14 @@ export function MessageList({
|
|||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
scrollButtonClassName,
|
scrollButtonClassName,
|
||||||
onClarificationSubmit,
|
onClarificationSubmit,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onSetupAgentApprove,
|
||||||
messageContentOverrides,
|
onSetupAgentReject,
|
||||||
externallyHiddenMessageIds,
|
onSetupWritingApprove,
|
||||||
onSetupAgentApprove,
|
onSetupWritingReject,
|
||||||
onSetupAgentReject,
|
onSetupWritingStart,
|
||||||
onSetupWritingApprove,
|
}: {
|
||||||
onSetupWritingReject,
|
|
||||||
onSetupWritingStart,
|
|
||||||
onOpenArtifact,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
fitMessageRowsToContent?: boolean;
|
fitMessageRowsToContent?: boolean;
|
||||||
@@ -119,19 +115,15 @@ export function MessageList({
|
|||||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||||
scrollButtonClassName?: string;
|
scrollButtonClassName?: string;
|
||||||
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>;
|
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
messageContentOverrides?: Record<string, string>;
|
|
||||||
externallyHiddenMessageIds?: Set<string>;
|
|
||||||
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(
|
||||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||||
@@ -163,33 +155,17 @@ export function MessageList({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allMessages = useMemo(() => {
|
const allMessages = useMemo(
|
||||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
||||||
return thread.messages
|
[thread.messages],
|
||||||
.filter((m) => !isHiddenFromUIMessage(m))
|
);
|
||||||
.map((message) => {
|
const messages = useMemo(
|
||||||
const override =
|
() =>
|
||||||
hasOverrides && message.id
|
hiddenMessageIds.size === 0
|
||||||
? messageContentOverrides?.[message.id]
|
? allMessages
|
||||||
: undefined;
|
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
||||||
return override === undefined
|
[allMessages, hiddenMessageIds],
|
||||||
? message
|
);
|
||||||
: ({ ...message, content: override } as Message);
|
|
||||||
});
|
|
||||||
}, [messageContentOverrides, thread.messages]);
|
|
||||||
const messages = useMemo(
|
|
||||||
() => {
|
|
||||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
|
||||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
|
||||||
? allMessages
|
|
||||||
: allMessages.filter(
|
|
||||||
(m) =>
|
|
||||||
!m.id ||
|
|
||||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
|
|
||||||
@@ -404,11 +380,10 @@ export function MessageList({
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onEditSave={onEditSave}
|
onMessageDeleted={
|
||||||
onMessageDeleted={
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
allowDelete ? handleMessageDeleted : undefined
|
|
||||||
}
|
}
|
||||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -513,11 +488,7 @@ export function MessageList({
|
|||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ArtifactFileList
|
<ArtifactFileList files={files} threadId={threadId} />
|
||||||
files={files}
|
|
||||||
threadId={threadId}
|
|
||||||
onOpenArtifact={onOpenArtifact}
|
|
||||||
/>
|
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ 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";
|
||||||
@@ -21,7 +18,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, type ThemeType } from "@/contexts/ThemeContext";
|
import { useTheme } 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";
|
||||||
@@ -64,17 +61,16 @@ import {
|
|||||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
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 }[] = [
|
||||||
{ value: "en-US", label: enUS.locale.localName },
|
{ value: "en-US", label: enUS.locale.localName },
|
||||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue";
|
||||||
|
|
||||||
function makeShortcutId() {
|
function makeShortcutId() {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
@@ -178,21 +174,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
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 =
|
||||||
@@ -227,24 +211,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetGlobalDefault = async (
|
const handleSetGlobalDefault = async (
|
||||||
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow",
|
value: "light" | "dark" | "lightblue",
|
||||||
) => {
|
) => {
|
||||||
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 === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
|
||||||
? "浅色"
|
|
||||||
: 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 : "保存失败");
|
||||||
@@ -394,44 +368,20 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
description: t.settings.appearance.darkDescription,
|
description: t.settings.appearance.darkDescription,
|
||||||
icon: MoonIcon,
|
icon: MoonIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lightblue",
|
id: "lightblue",
|
||||||
label: t.settings.appearance.lightblue,
|
label: t.settings.appearance.lightblue,
|
||||||
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,
|
||||||
t.settings.appearance.darkDescription,
|
t.settings.appearance.darkDescription,
|
||||||
t.settings.appearance.light,
|
t.settings.appearance.light,
|
||||||
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,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -442,34 +392,18 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
description={t.settings.appearance.themeDescription}
|
description={t.settings.appearance.themeDescription}
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
{themeOptions.map((option) => (
|
{themeOptions.map((option) => (
|
||||||
<ThemePreviewCard
|
<ThemePreviewCard
|
||||||
key={option.id}
|
key={option.id}
|
||||||
icon={option.icon}
|
icon={option.icon}
|
||||||
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" | "lightgreen" | "iceblue" | "lightyellow"}
|
mode={option.id as "light" | "dark" | "lightblue"}
|
||||||
onSelect={(value) => setTheme(value)}
|
onSelect={(value) => setTheme(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{(appearance?.custom_themes ?? [])
|
</div>
|
||||||
.filter((preset) => preset.enabled)
|
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
|
||||||
.map((preset) => (
|
|
||||||
<ThemePreviewCard
|
|
||||||
key={preset.id}
|
|
||||||
icon={PaletteIcon}
|
|
||||||
label={preset.name}
|
|
||||||
description={`自定义主题 · 主色 ${preset.primary}`}
|
|
||||||
active={currentTheme === preset.id}
|
|
||||||
mode={preset.id}
|
|
||||||
previewDark={preset.mode === "dark"}
|
|
||||||
accentColor={preset.primary}
|
|
||||||
onSelect={(value) => setTheme(value)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -607,80 +541,30 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
<MoonIcon className="mr-1.5 size-4" />
|
<MoonIcon className="mr-1.5 size-4" />
|
||||||
深色
|
深色
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void handleSetGlobalDefault("lightblue")}
|
onClick={() => void handleSetGlobalDefault("lightblue")}
|
||||||
disabled={updateAppearance.isPending}
|
disabled={updateAppearance.isPending}
|
||||||
>
|
>
|
||||||
<DropletIcon className="mr-1.5 size-4" />
|
<DropletIcon className="mr-1.5 size-4" />
|
||||||
浅蓝
|
浅蓝
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<p className="text-muted-foreground ml-2 text-xs">
|
||||||
type="button"
|
当前系统默认:
|
||||||
variant={globalDefault === "lightgreen" ? "default" : "outline"}
|
{globalDefault === "light"
|
||||||
size="sm"
|
? "浅色"
|
||||||
onClick={() => void handleSetGlobalDefault("lightgreen")}
|
: globalDefault === "dark"
|
||||||
disabled={updateAppearance.isPending}
|
? "深色"
|
||||||
>
|
: "浅蓝"}
|
||||||
<LeafIcon className="mr-1.5 size-4" />
|
</p>
|
||||||
绿色
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={globalDefault === "iceblue" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleSetGlobalDefault("iceblue")}
|
|
||||||
disabled={updateAppearance.isPending}
|
|
||||||
>
|
|
||||||
<SnowflakeIcon className="mr-1.5 size-4" />
|
|
||||||
冰川蓝
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={globalDefault === "lightyellow" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleSetGlobalDefault("lightyellow")}
|
|
||||||
disabled={updateAppearance.isPending}
|
|
||||||
>
|
|
||||||
<SunIcon className="mr-1.5 size-4" />
|
|
||||||
浅黄色
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
document
|
|
||||||
.getElementById("custom-theme-library")
|
|
||||||
?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PaletteIcon className="mr-1.5 size-4" />
|
|
||||||
管理自定义主题
|
|
||||||
</Button>
|
|
||||||
<p className="text-muted-foreground ml-2 text-xs">
|
|
||||||
当前系统默认:{globalDefaultLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div id="custom-theme-library" className="scroll-mt-4">
|
|
||||||
<Separator />
|
|
||||||
<SettingsSection
|
|
||||||
title="自定义主题库(管理员)"
|
|
||||||
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
|
|
||||||
>
|
|
||||||
<CustomThemeLibrarySettings />
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@@ -1181,24 +1065,19 @@ function CompactShortcutSettings({
|
|||||||
function ThemePreviewCard({
|
function ThemePreviewCard({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
active,
|
active,
|
||||||
mode,
|
mode,
|
||||||
previewDark,
|
onSelect,
|
||||||
accentColor,
|
}: {
|
||||||
onSelect,
|
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
}: {
|
label: string;
|
||||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
description: string;
|
||||||
label: string;
|
active: boolean;
|
||||||
description: string;
|
mode: "light" | "dark" | "lightblue";
|
||||||
active: boolean;
|
onSelect: (mode: "light" | "dark" | "lightblue") => void;
|
||||||
mode: ThemeType;
|
}) {
|
||||||
previewDark?: boolean;
|
const previewMode = mode;
|
||||||
accentColor?: string;
|
|
||||||
onSelect: (mode: ThemeType) => void;
|
|
||||||
}) {
|
|
||||||
const previewMode = mode;
|
|
||||||
const isDarkPreview = previewDark ?? previewMode === "dark";
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1224,38 +1103,23 @@ 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",
|
||||||
isDarkPreview
|
previewMode === "dark"
|
||||||
? "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"
|
||||||
: previewMode === "lightgreen"
|
: "border-slate-200 bg-white text-slate-900",
|
||||||
? "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",
|
||||||
accentColor
|
previewMode === "dark"
|
||||||
? ""
|
? "bg-emerald-400"
|
||||||
: isDarkPreview
|
: previewMode === "lightblue"
|
||||||
? "bg-emerald-400"
|
? "bg-blue-500"
|
||||||
: previewMode === "lightblue"
|
: "bg-emerald-500",
|
||||||
? "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" />
|
||||||
|
|||||||
@@ -1,753 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronUpIcon,
|
|
||||||
CheckIcon,
|
|
||||||
MoonIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RotateCcwIcon,
|
|
||||||
SaveIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
SunIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useId, useMemo, useState, type CSSProperties } from "react";
|
|
||||||
import { ColorPickerPanel } from "tdesign-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import type {
|
|
||||||
CustomThemeColorOverrides,
|
|
||||||
CustomThemePreset,
|
|
||||||
} from "@/core/system-settings/api";
|
|
||||||
import {
|
|
||||||
useAppearanceSettings,
|
|
||||||
useUpdateAppearanceSettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
|
||||||
import {
|
|
||||||
CUSTOM_THEME_ADVANCED_KEYS,
|
|
||||||
getCustomThemeAutomaticTokens,
|
|
||||||
getCustomThemeTokens,
|
|
||||||
normalizeHexColor,
|
|
||||||
customThemeTextContrast,
|
|
||||||
type CustomThemeAdvancedKey,
|
|
||||||
} from "@/core/theme/custom-theme";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const MAX_CUSTOM_THEMES = 12;
|
|
||||||
const SUGGESTED_COLORS = [
|
|
||||||
"#9BCEF1",
|
|
||||||
"#F3FBD9",
|
|
||||||
"#7AB8F5",
|
|
||||||
"#8ED1B2",
|
|
||||||
"#C6B4F6",
|
|
||||||
"#F3C5A6",
|
|
||||||
"#F3B1B7",
|
|
||||||
"#63C4C8",
|
|
||||||
];
|
|
||||||
const TEXT_COLORS = ["#202A3A", "#172033", "#2D587A", "#4C6330", "#F4F7FB", "#FFFFFF"];
|
|
||||||
|
|
||||||
const ADVANCED_COLOR_FIELDS: Array<{
|
|
||||||
key: CustomThemeAdvancedKey;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
token: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
key: "button_hover",
|
|
||||||
label: "按钮悬停色",
|
|
||||||
description: "主按钮悬停时的背景色",
|
|
||||||
token: "--theme-button-hover",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "selected_background",
|
|
||||||
label: "选中背景",
|
|
||||||
description: "左侧菜单、卡片选中时的浅背景",
|
|
||||||
token: "--theme-selected-background",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "border",
|
|
||||||
label: "边框色",
|
|
||||||
description: "卡片、输入框和分隔线",
|
|
||||||
token: "--border",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "muted",
|
|
||||||
label: "弱化背景",
|
|
||||||
description: "次级区域与弱化容器",
|
|
||||||
token: "--muted",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "muted_text",
|
|
||||||
label: "辅助文字",
|
|
||||||
description: "说明、占位和次要信息文字",
|
|
||||||
token: "--muted-foreground",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "destructive",
|
|
||||||
label: "删除/危险色",
|
|
||||||
description: "删除按钮、错误与危险操作的颜色",
|
|
||||||
token: "--destructive",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "brand_title",
|
|
||||||
label: "栏目标题文字",
|
|
||||||
description: "业务链、分析记录等带图标栏目标题",
|
|
||||||
token: "--brand-title-foreground",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "primary_foreground",
|
|
||||||
label: "主色块文字(Logo/状态标签)",
|
|
||||||
description: "Logo 的 C、启用状态和默认标签;不影响主按钮文字",
|
|
||||||
token: "--primary-foreground",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function makeThemeId(): `custom-theme-${string}` {
|
|
||||||
const suffix =
|
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
|
|
||||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
return `custom-theme-${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newCustomTheme(order: number): CustomThemePreset {
|
|
||||||
return {
|
|
||||||
id: makeThemeId(),
|
|
||||||
name: `自定义主题 ${order + 1}`,
|
|
||||||
primary: "#9BCEF1",
|
|
||||||
text_color: null,
|
|
||||||
mode: "light",
|
|
||||||
overrides: {},
|
|
||||||
enabled: true,
|
|
||||||
sort_order: order,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeColorOverrides(
|
|
||||||
overrides: CustomThemeColorOverrides | undefined,
|
|
||||||
): CustomThemeColorOverrides {
|
|
||||||
const normalized: CustomThemeColorOverrides = {};
|
|
||||||
for (const key of CUSTOM_THEME_ADVANCED_KEYS) {
|
|
||||||
const value = overrides?.[key];
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
|
||||||
normalized[key] = normalizeHexColor(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeThemeDrafts(
|
|
||||||
themes: CustomThemePreset[] | undefined,
|
|
||||||
): CustomThemePreset[] {
|
|
||||||
return [...(themes ?? [])]
|
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
|
||||||
.map((theme, index) => ({
|
|
||||||
...theme,
|
|
||||||
id: theme.id || makeThemeId(),
|
|
||||||
name: theme.name || `自定义主题 ${index + 1}`,
|
|
||||||
primary: normalizeHexColor(theme.primary),
|
|
||||||
text_color:
|
|
||||||
typeof theme.text_color === "string" && theme.text_color.trim()
|
|
||||||
? normalizeHexColor(theme.text_color)
|
|
||||||
: null,
|
|
||||||
mode: theme.mode === "dark" ? "dark" : "light",
|
|
||||||
overrides: normalizeColorOverrides(theme.overrides),
|
|
||||||
enabled: theme.enabled !== false,
|
|
||||||
sort_order: index,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the TDesign panel inside the settings dialog instead of its default
|
|
||||||
* body-level popup. The dialog otherwise treats clicks inside that portal as
|
|
||||||
* outside interactions and closes the picker while a colour is being chosen.
|
|
||||||
*/
|
|
||||||
function InlineThemeColorPicker({
|
|
||||||
value,
|
|
||||||
fallback,
|
|
||||||
swatchColors,
|
|
||||||
onCommit,
|
|
||||||
ariaLabel = "选择颜色",
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
fallback?: string;
|
|
||||||
swatchColors: string[];
|
|
||||||
onCommit: (value: string) => void;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}) {
|
|
||||||
const normalizedValue = normalizeHexColor(value, fallback);
|
|
||||||
const [pendingValue, setPendingValue] = useState(normalizedValue);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const panelId = useId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) setPendingValue(normalizedValue);
|
|
||||||
}, [normalizedValue, open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-fit">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-10 min-w-32 items-center gap-2 rounded-md border bg-background px-2 font-mono text-sm shadow-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={panelId}
|
|
||||||
onClick={() => setOpen((visible) => !visible)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="size-7 rounded border"
|
|
||||||
style={{ backgroundColor: pendingValue }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{pendingValue}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
id={panelId}
|
|
||||||
className="mt-2 w-fit rounded-lg border bg-background p-2 shadow-lg"
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ColorPickerPanel
|
|
||||||
value={pendingValue}
|
|
||||||
format="HEX"
|
|
||||||
colorModes={["monochrome"]}
|
|
||||||
enableAlpha={false}
|
|
||||||
swatchColors={swatchColors}
|
|
||||||
onChange={(color) => setPendingValue(normalizeHexColor(String(color), fallback))}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 flex justify-end gap-2 border-t pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setPendingValue(normalizedValue);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
onCommit(pendingValue);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
应用颜色
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
|
|
||||||
const tokens = getCustomThemeTokens(theme) as CSSProperties;
|
|
||||||
const buttonContrast = customThemeTextContrast(theme);
|
|
||||||
const isButtonContrastLow = buttonContrast < 4.5;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="overflow-hidden rounded-lg border"
|
|
||||||
style={tokens}
|
|
||||||
aria-label={`${theme.name} 的颜色预览`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--background)] px-3 py-2">
|
|
||||||
<span
|
|
||||||
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
C
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-semibold text-[var(--foreground)]">
|
|
||||||
实时预览
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-[11px]",
|
|
||||||
isButtonContrastLow
|
|
||||||
? "text-amber-700 dark:text-amber-300"
|
|
||||||
: "text-[var(--muted-foreground)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
主按钮对比度 {buttonContrast.toFixed(1)}:1
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 bg-[var(--background)] p-3 sm:grid-cols-[1fr_auto]">
|
|
||||||
<div className="rounded-md border border-[var(--border)] bg-[var(--card)] p-2.5">
|
|
||||||
<div className="text-xs font-semibold text-[var(--brand-title-foreground)]">
|
|
||||||
{theme.name || "未命名主题"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] text-[var(--muted-foreground)]">
|
|
||||||
页面正文自动生成;主按钮文字可单独指定。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md bg-[var(--primary)] px-3 py-2 text-xs font-semibold text-[var(--button-foreground,var(--primary-foreground))] shadow-sm"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
主按钮
|
|
||||||
</button>
|
|
||||||
{isButtonContrastLow && (
|
|
||||||
<p className="sm:col-span-2 text-[11px] text-amber-700 dark:text-amber-300">
|
|
||||||
当前按钮文字会按您的颜色设置展示,但对比度低于 4.5:1,建议改用更深的主色或恢复自动。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdvancedThemeColorControls({
|
|
||||||
theme,
|
|
||||||
onPatch,
|
|
||||||
}: {
|
|
||||||
theme: CustomThemePreset;
|
|
||||||
onPatch: (patch: Partial<CustomThemePreset>) => void;
|
|
||||||
}) {
|
|
||||||
const automaticTokens = getCustomThemeAutomaticTokens({
|
|
||||||
primary: theme.primary,
|
|
||||||
mode: theme.mode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setOverride = (key: CustomThemeAdvancedKey, value: string) => {
|
|
||||||
const overrides = { ...(theme.overrides ?? {}) };
|
|
||||||
if (!value.trim()) {
|
|
||||||
delete overrides[key];
|
|
||||||
} else {
|
|
||||||
overrides[key] = value;
|
|
||||||
}
|
|
||||||
onPatch({ overrides });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 rounded-lg border border-dashed bg-muted/15 p-3">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">更多配色</div>
|
|
||||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
|
||||||
默认由主色自动推导。仅在品牌规范有明确要求时,选择单项覆盖;“恢复自动”会重新跟随主色。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
||||||
已覆盖 {Object.keys(theme.overrides ?? {}).length} 项
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
|
||||||
{ADVANCED_COLOR_FIELDS.map((field) => {
|
|
||||||
const explicit = theme.overrides?.[field.key];
|
|
||||||
const automatic = automaticTokens[field.token] ?? "#0062D9";
|
|
||||||
const value = explicit ?? automatic;
|
|
||||||
return (
|
|
||||||
<div key={field.key} className="rounded-md border bg-background p-3">
|
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold">{field.label}</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
|
||||||
{field.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{explicit ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
onClick={() => setOverride(field.key, "")}
|
|
||||||
>
|
|
||||||
<RotateCcwIcon className="mr-1 size-3.5" />
|
|
||||||
恢复自动
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
|
||||||
自动
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
|
||||||
<InlineThemeColorPicker
|
|
||||||
value={value}
|
|
||||||
swatchColors={SUGGESTED_COLORS}
|
|
||||||
onCommit={(color) => setOverride(field.key, color)}
|
|
||||||
ariaLabel={`${theme.name || "自定义主题"}${field.label}选色器`}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
maxLength={7}
|
|
||||||
className="h-8 w-28 font-mono text-xs uppercase"
|
|
||||||
onChange={(event) => setOverride(field.key, event.target.value)}
|
|
||||||
onBlur={(event) =>
|
|
||||||
setOverride(
|
|
||||||
field.key,
|
|
||||||
event.target.value.trim()
|
|
||||||
? normalizeHexColor(event.target.value)
|
|
||||||
: "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label={`${theme.name || "自定义主题"}${field.label}十六进制值`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!explicit && (
|
|
||||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
|
||||||
当前自动值:{automatic}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CustomThemeLibrarySettings() {
|
|
||||||
const { data: appearance } = useAppearanceSettings();
|
|
||||||
const updateAppearance = useUpdateAppearanceSettings();
|
|
||||||
const [drafts, setDrafts] = useState<CustomThemePreset[]>([]);
|
|
||||||
const [advancedThemeIds, setAdvancedThemeIds] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDrafts(normalizeThemeDrafts(appearance?.custom_themes));
|
|
||||||
}, [appearance?.custom_themes]);
|
|
||||||
|
|
||||||
const hasChanges = useMemo(
|
|
||||||
() =>
|
|
||||||
JSON.stringify(normalizeThemeDrafts(appearance?.custom_themes)) !==
|
|
||||||
JSON.stringify(normalizeThemeDrafts(drafts)),
|
|
||||||
[appearance?.custom_themes, drafts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const patchTheme = (id: string, patch: Partial<CustomThemePreset>) => {
|
|
||||||
setDrafts((items) =>
|
|
||||||
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAdvancedTheme = (id: string) => {
|
|
||||||
setAdvancedThemeIds((ids) => {
|
|
||||||
const next = new Set(ids);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const cleaned = normalizeThemeDrafts(drafts).map((theme) => ({
|
|
||||||
...theme,
|
|
||||||
name: theme.name.trim(),
|
|
||||||
primary: normalizeHexColor(theme.primary),
|
|
||||||
}));
|
|
||||||
if (cleaned.some((theme) => !theme.name)) {
|
|
||||||
toast.error("请为每个主题填写名称");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const names = cleaned.map((theme) => theme.name.toLocaleLowerCase());
|
|
||||||
if (new Set(names).size !== names.length) {
|
|
||||||
toast.error("主题名称不能重复");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateAppearance.mutateAsync({ custom_themes: cleaned });
|
|
||||||
toast.success("自定义主题库已保存,所有用户可在主题切换器中选择已启用主题");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存主题库失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetDefault = async (theme: CustomThemePreset) => {
|
|
||||||
if (!theme.enabled || appearance?.default_theme === theme.id) return;
|
|
||||||
if (!(appearance?.custom_themes ?? []).some((preset) => preset.id === theme.id)) {
|
|
||||||
toast.info("请先保存主题库,再将新主题设为系统默认");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateAppearance.mutateAsync({ default_theme: theme.id });
|
|
||||||
toast.success(`已将“${theme.name || "自定义主题"}”设为系统默认主题`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "设置系统默认主题失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
|
|
||||||
默认只需设置主色、主按钮文字与明暗模式;系统会自动生成页面正文、背景、边框、弱化、选中和悬停色。点击“更多”后才按需覆盖个别交互色,停用后不再向用户展示,最多可保存 {MAX_CUSTOM_THEMES} 个。
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setDrafts((items) =>
|
|
||||||
items.length >= MAX_CUSTOM_THEMES
|
|
||||||
? items
|
|
||||||
: [...items, newCustomTheme(items.length)],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={drafts.length >= MAX_CUSTOM_THEMES || updateAppearance.isPending}
|
|
||||||
>
|
|
||||||
<PlusIcon className="mr-1.5 size-4" />
|
|
||||||
添加主题
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
disabled={!hasChanges || updateAppearance.isPending}
|
|
||||||
>
|
|
||||||
<SaveIcon className="mr-1.5 size-4" />
|
|
||||||
保存主题库
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed bg-muted/20 px-5 py-8 text-center">
|
|
||||||
<PaletteIcon className="mx-auto mb-2 size-5 text-muted-foreground" />
|
|
||||||
<div className="text-sm font-medium">还没有自定义主题</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
添加后,用户可以从右上角主题切换器中自行选择。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{drafts.map((theme, index) => {
|
|
||||||
const isDefault = appearance?.default_theme === theme.id;
|
|
||||||
const advancedOpen = advancedThemeIds.has(theme.id);
|
|
||||||
const automaticTokens = getCustomThemeAutomaticTokens({
|
|
||||||
primary: theme.primary,
|
|
||||||
mode: theme.mode,
|
|
||||||
});
|
|
||||||
const textColor = theme.text_color ?? automaticTokens["--foreground"] ?? "#202A3A";
|
|
||||||
return (
|
|
||||||
<div key={theme.id} className="space-y-4 rounded-xl border bg-card p-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<PaletteIcon className="size-4 text-primary" />
|
|
||||||
<span className="text-sm font-semibold">主题 {index + 1}</span>
|
|
||||||
{isDefault && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
|
||||||
<CheckIcon className="size-3" />
|
|
||||||
系统默认
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Switch
|
|
||||||
checked={theme.enabled}
|
|
||||||
onCheckedChange={(enabled) => patchTheme(theme.id, { enabled })}
|
|
||||||
aria-label={`启用${theme.name || "自定义主题"}`}
|
|
||||||
/>
|
|
||||||
{theme.enabled ? "启用" : "停用"}
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={isDefault ? "default" : "outline"}
|
|
||||||
onClick={() => void handleSetDefault(theme)}
|
|
||||||
disabled={!theme.enabled || isDefault || hasChanges || updateAppearance.isPending}
|
|
||||||
>
|
|
||||||
设为默认
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => toggleAdvancedTheme(theme.id)}
|
|
||||||
aria-expanded={advancedOpen}
|
|
||||||
aria-controls={`${theme.id}-advanced-colors`}
|
|
||||||
>
|
|
||||||
<SlidersHorizontalIcon className="mr-1.5 size-4" />
|
|
||||||
更多
|
|
||||||
{advancedOpen ? (
|
|
||||||
<ChevronUpIcon className="ml-1 size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronDownIcon className="ml-1 size-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
onClick={() =>
|
|
||||||
setDrafts((items) =>
|
|
||||||
items
|
|
||||||
.filter((item) => item.id !== theme.id)
|
|
||||||
.map((item, order) => ({ ...item, sort_order: order })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title="删除主题"
|
|
||||||
aria-label={`删除${theme.name || "自定义主题"}`}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(180px,0.55fr)_150px_150px]">
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium">主题名称</span>
|
|
||||||
<Input
|
|
||||||
value={theme.name}
|
|
||||||
maxLength={32}
|
|
||||||
onChange={(event) => patchTheme(theme.id, { name: event.target.value })}
|
|
||||||
placeholder="例如:品牌浅蓝"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium">基础模式</span>
|
|
||||||
<Select
|
|
||||||
value={theme.mode}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
patchTheme(theme.id, {
|
|
||||||
mode: value === "dark" ? "dark" : "light",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="light">
|
|
||||||
<span className="inline-flex items-center gap-1.5"><SunIcon className="size-3.5" />浅色</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="dark">
|
|
||||||
<span className="inline-flex items-center gap-1.5"><MoonIcon className="size-3.5" />深色</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium">主色值</span>
|
|
||||||
<Input
|
|
||||||
value={theme.primary}
|
|
||||||
maxLength={7}
|
|
||||||
className="font-mono uppercase"
|
|
||||||
onChange={(event) => patchTheme(theme.id, { primary: event.target.value })}
|
|
||||||
onBlur={() =>
|
|
||||||
patchTheme(theme.id, {
|
|
||||||
primary: normalizeHexColor(theme.primary),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label={`${theme.name || "自定义主题"}主色十六进制值`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-medium">主按钮文字</span>
|
|
||||||
{theme.text_color ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-1.5 text-[11px]"
|
|
||||||
onClick={() => patchTheme(theme.id, { text_color: null })}
|
|
||||||
>
|
|
||||||
自动
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-muted-foreground">自动</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={textColor}
|
|
||||||
maxLength={7}
|
|
||||||
className="font-mono uppercase"
|
|
||||||
onChange={(event) =>
|
|
||||||
patchTheme(theme.id, { text_color: event.target.value })
|
|
||||||
}
|
|
||||||
onBlur={(event) =>
|
|
||||||
patchTheme(theme.id, {
|
|
||||||
text_color: event.target.value.trim()
|
|
||||||
? normalizeHexColor(event.target.value)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label={`${theme.name || "自定义主题"}主按钮文字十六进制值`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/15 p-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium">选择主色</span>
|
|
||||||
<InlineThemeColorPicker
|
|
||||||
value={theme.primary}
|
|
||||||
swatchColors={SUGGESTED_COLORS}
|
|
||||||
onCommit={(primary) => patchTheme(theme.id, { primary })}
|
|
||||||
ariaLabel={`${theme.name || "自定义主题"}主色选色器`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium">选择主按钮文字</span>
|
|
||||||
<InlineThemeColorPicker
|
|
||||||
value={textColor}
|
|
||||||
fallback="#202A3A"
|
|
||||||
swatchColors={TEXT_COLORS}
|
|
||||||
onCommit={(text_color) => patchTheme(theme.id, { text_color })}
|
|
||||||
ariaLabel={`${theme.name || "自定义主题"}主按钮文字选色器`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5 pb-0.5">
|
|
||||||
{SUGGESTED_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"size-7 rounded-md border shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
normalizeHexColor(theme.primary) === color
|
|
||||||
? "ring-2 ring-primary ring-offset-2"
|
|
||||||
: "border-border",
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
onClick={() => patchTheme(theme.id, { primary: color })}
|
|
||||||
title={`使用 ${color}`}
|
|
||||||
aria-label={`使用主色 ${color}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{advancedOpen && (
|
|
||||||
<div id={`${theme.id}-advanced-colors`}>
|
|
||||||
<AdvancedThemeColorControls
|
|
||||||
theme={theme}
|
|
||||||
onPatch={(patch) => patchTheme(theme.id, patch)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CustomThemePreview theme={{ ...theme, primary: normalizeHexColor(theme.primary) }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,10 +23,11 @@ import {
|
|||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
Upload,
|
Upload,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
XIcon,
|
WorkflowIcon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -50,13 +51,6 @@ 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";
|
||||||
@@ -470,11 +464,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<TagSearchBar
|
<TagSearchBar
|
||||||
targetType="skill"
|
targetType="skill"
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
tagIds={tagIds}
|
tagIds={tagIds}
|
||||||
onTagIdsChange={setTagIds}
|
onTagIdsChange={setTagIds}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
@@ -497,16 +491,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isLoading && !error && (
|
{publisher && (
|
||||||
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
<div className="flex items-center gap-2">
|
||||||
共 {orderedSkills.length} 个技能
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{publisher && (
|
|
||||||
<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"
|
||||||
@@ -747,9 +736,9 @@ 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={cn(isCard && "truncate font-medium")}>
|
<span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
|
||||||
{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() && (
|
||||||
@@ -907,12 +896,12 @@ export function SkillRow({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const actionButtons = (
|
const actionButtons = (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 shrink-0"
|
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"
|
||||||
onClick={() => setTagPickerOpen(true)}
|
onClick={() => setTagPickerOpen(true)}
|
||||||
title={t.adminTags.manageTags}
|
title={t.adminTags.manageTags}
|
||||||
>
|
>
|
||||||
@@ -922,8 +911,8 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 shrink-0",
|
"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",
|
||||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||||
)}
|
)}
|
||||||
disabled={setFeatured.isPending}
|
disabled={setFeatured.isPending}
|
||||||
@@ -943,8 +932,8 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 shrink-0",
|
"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",
|
||||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||||
)}
|
)}
|
||||||
disabled={setPinned.isPending}
|
disabled={setPinned.isPending}
|
||||||
@@ -964,8 +953,8 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 shrink-0",
|
"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",
|
||||||
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}
|
||||||
@@ -1002,9 +991,9 @@ export function SkillRow({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 shrink-0"
|
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"
|
||||||
disabled={duplicateSkillMutation.isPending}
|
disabled={duplicateSkillMutation.isPending}
|
||||||
onClick={handleOpenDuplicate}
|
onClick={handleOpenDuplicate}
|
||||||
title="复制为新技能"
|
title="复制为新技能"
|
||||||
@@ -1013,9 +1002,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"
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
onClick={() => setConfirmDeleteOpen(true)}
|
onClick={() => setConfirmDeleteOpen(true)}
|
||||||
title={t.settings.skills.deleteAction}
|
title={t.settings.skills.deleteAction}
|
||||||
>
|
>
|
||||||
@@ -1023,41 +1012,124 @@ export function SkillRow({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
|
||||||
|
? {
|
||||||
|
Icon: GlobeIcon,
|
||||||
|
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
|
||||||
|
}
|
||||||
|
: skill.name.includes("doc") || skill.name.includes("file")
|
||||||
|
? {
|
||||||
|
Icon: FileArchive,
|
||||||
|
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
|
||||||
|
}
|
||||||
|
: skill.source === "agent"
|
||||||
|
? {
|
||||||
|
Icon: WorkflowIcon,
|
||||||
|
className: "bg-[#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-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
<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
|
<div
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
>
|
>
|
||||||
{badgeGroup}
|
<div className="flex items-start gap-3.5">
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||||
{desensitize(skill.description ?? "")}
|
<SkillIcon className="size-6" />
|
||||||
</p>
|
</div>
|
||||||
{metaNodes}
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
{badgeGroup}
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
{actionButtons}
|
{desensitize(skill.description ?? "")}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<ItemContent
|
<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")}>
|
||||||
className="cursor-pointer"
|
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||||
onClick={() => setDetailOpen(true)}
|
{skill.enabled ? "已启用" : "未启用"}
|
||||||
>
|
</Badge>
|
||||||
<ItemTitle>{badgeGroup}</ItemTitle>
|
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
|
||||||
<ItemDescription className="line-clamp-4">
|
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
|
||||||
{desensitize(skill.description ?? "")}
|
</div>
|
||||||
</ItemDescription>
|
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
|
||||||
{metaNodes}
|
</div>
|
||||||
</ItemContent>
|
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
|
||||||
<ItemActions>{actionButtons}</ItemActions>
|
{actionArea}
|
||||||
</Item>
|
</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">
|
||||||
|
<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">
|
||||||
|
<div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
|
||||||
|
<div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||||
|
<SkillIcon className="size-7" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
|
{badgeGroup}
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
|
||||||
|
{desensitize(skill.description ?? "")}
|
||||||
|
</p>
|
||||||
|
{!isBuiltin && skill.owner_username && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!skill.owner_user_id || !onPublisherClick}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!skill.owner_user_id || !onPublisherClick) return;
|
||||||
|
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
|
||||||
|
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-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>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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);
|
||||||
@@ -81,7 +82,7 @@ export function SourceCard({
|
|||||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !previewTarget) {
|
if (!open || !showOriginal || !previewTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -115,7 +116,7 @@ export function SourceCard({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
}, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -128,7 +129,10 @@ 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={() => setOpen(true)}
|
onClick={() => {
|
||||||
|
setShowOriginal(false);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{showReferenceNumber && (
|
{showReferenceNumber && (
|
||||||
@@ -168,7 +172,15 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
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">
|
||||||
@@ -205,7 +217,27 @@ 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}
|
||||||
@@ -249,12 +281,27 @@ type WeKnoraSourceContext = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
|
||||||
|
return parseWeKnoraPreviewUrl(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
const normalizedUrl = url
|
||||||
|
?.trim()
|
||||||
|
.replace(/^[\u200B-\u200D\uFEFF]+/, "");
|
||||||
|
const marker = "/weknora-source-preview?";
|
||||||
|
const start = normalizedUrl?.indexOf(marker) ?? -1;
|
||||||
|
if (start < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const queryIndex = url.indexOf("?");
|
// Some MCP tool results wrap the relative pointer in formatting characters
|
||||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
// or append a newline. Keep accepting that safe internal path, but never
|
||||||
|
// treat a normal external URL as an original-document preview.
|
||||||
|
const previewUrl = normalizedUrl!
|
||||||
|
.slice(start)
|
||||||
|
.split(/[\r\n\s]/, 1)[0];
|
||||||
|
const queryIndex = previewUrl.indexOf("?");
|
||||||
|
const params = new URLSearchParams(previewUrl.slice(queryIndex + 1));
|
||||||
const 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 { User } from "lucide-react";
|
import { UserRound } 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,7 +15,13 @@ import { cn } from "@/lib/utils";
|
|||||||
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
||||||
* 而非 Header 的白字贴 banner 方案。
|
* 而非 Header 的白字贴 banner 方案。
|
||||||
*/
|
*/
|
||||||
export function UserIdentityBadge({ className }: { className?: string }) {
|
export function UserIdentityBadge({
|
||||||
|
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.
|
||||||
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex shrink-0 items-center gap-2", className)}
|
data-workspace-user={variant}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-2",
|
||||||
|
variant === "topbar" &&
|
||||||
|
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
title={positionName ? `${displayName}·${positionName}` : displayName}
|
title={positionName ? `${displayName}·${positionName}` : displayName}
|
||||||
>
|
>
|
||||||
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
|
<div
|
||||||
<User className="size-3.5" />
|
className={cn(
|
||||||
|
"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 className="hidden min-w-0 text-left sm:block">
|
<div
|
||||||
|
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>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
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,8 +15,7 @@ export function Welcome({
|
|||||||
className?: string;
|
className?: string;
|
||||||
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(() => {
|
||||||
@@ -38,7 +36,7 @@ export function Welcome({
|
|||||||
t.welcome.createYourOwnSkill
|
t.welcome.createYourOwnSkill
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
|
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} 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,
|
||||||
@@ -93,9 +95,15 @@ export function WorkspaceHeader({
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
{rightActions ? (
|
<div className="flex items-center gap-2 px-4">
|
||||||
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
{rightActions}
|
||||||
) : null}
|
<ThemeSwitcher tone="content" />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
|
||||||
|
/>
|
||||||
|
<UserIdentityBadge variant="topbar" />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -123,7 +131,8 @@ function nameOfSegment(
|
|||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
) {
|
||||||
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;
|
||||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
if (segment === "agents") return t.agents.title;
|
||||||
}
|
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, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
import { BookOpen, BotIcon, BrainIcon, 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 =
|
||||||
"workspace-nav-item text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
|
"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 =
|
||||||
"workspace-nav-new-chat rounded-xl h-9 px-3 text-primary transition-colors hover:bg-primary/10 hover:text-primary " +
|
"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 =
|
||||||
"workspace-compact-new-chat h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
|
"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,18 +184,6 @@ 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,6 +11,7 @@ import {
|
|||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
MessageCircleQuestionIcon,
|
MessageCircleQuestionIcon,
|
||||||
|
PlugIcon,
|
||||||
ScrollTextIcon,
|
ScrollTextIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -218,14 +219,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")}
|
||||||
|
|||||||
@@ -1,140 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarHeader,
|
SidebarContent,
|
||||||
SidebarContent,
|
useSidebar,
|
||||||
SidebarFooter,
|
} from "@/components/ui/sidebar";
|
||||||
SidebarRail,
|
import { cn } from "@/lib/utils";
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
import { CmChatSidebar } from "./cm-chat-sidebar";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
|
||||||
import { RecentChatList } from "./recent-chat-list";
|
|
||||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
|
||||||
import { RecentWritingList } from "./recent-writing-list";
|
|
||||||
import { WorkspaceHeader } from "./workspace-header";
|
|
||||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
|
||||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
|
||||||
|
|
||||||
export function WorkspaceSidebar({
|
export function WorkspaceSidebar({
|
||||||
compactChrome = false,
|
|
||||||
forceCollapsed = false,
|
|
||||||
className,
|
className,
|
||||||
|
compactChrome: _compactChrome,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||||
compactChrome?: boolean;
|
const { open: isSidebarOpen, toggleSidebar } = useSidebar();
|
||||||
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");
|
|
||||||
|
|
||||||
if (forceCollapsed) {
|
return (
|
||||||
return (
|
<div
|
||||||
<Sidebar
|
aria-hidden={false}
|
||||||
variant="sidebar"
|
|
||||||
collapsible="icon"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||||
className,
|
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
||||||
)}
|
)}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<SidebarContent>
|
<Sidebar
|
||||||
<WorkspaceNavChatList />
|
variant="sidebar"
|
||||||
</SidebarContent>
|
collapsible="none"
|
||||||
</Sidebar>
|
className={cn(
|
||||||
);
|
"h-full border-r border-border",
|
||||||
}
|
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
||||||
|
"[&_[data-slot=sidebar-container]]:bg-white [&_[data-slot=sidebar-inner]]:bg-white",
|
||||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
className,
|
||||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
)}
|
||||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
{...props}
|
||||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
>
|
||||||
if (compactChrome) {
|
<SidebarContent>
|
||||||
return (
|
<CmChatSidebar onCollapse={toggleSidebar} collapsed={!isSidebarOpen} />
|
||||||
<div
|
</SidebarContent>
|
||||||
aria-hidden={!isSidebarOpen}
|
</Sidebar>
|
||||||
className={cn(
|
</div>
|
||||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
);
|
||||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
}
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Sidebar
|
|
||||||
variant="sidebar"
|
|
||||||
collapsible="none"
|
|
||||||
className={cn(
|
|
||||||
"h-full w-(--sidebar-width) border-r border-border",
|
|
||||||
useWhiteSidebar &&
|
|
||||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SidebarContent>
|
|
||||||
{isGeneralQa ? (
|
|
||||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
|
||||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
|
||||||
<GeneralQaSidebar />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
|
||||||
<RecentChatList compact />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Sidebar
|
|
||||||
variant="sidebar"
|
|
||||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
|
||||||
// 非紧凑沿用 icon。
|
|
||||||
collapsible={compactChrome ? "none" : "icon"}
|
|
||||||
className={cn(
|
|
||||||
useWhiteSidebar &&
|
|
||||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{!compactChrome && (
|
|
||||||
<SidebarHeader className="py-0">
|
|
||||||
<WorkspaceHeader />
|
|
||||||
</SidebarHeader>
|
|
||||||
)}
|
|
||||||
<SidebarContent>
|
|
||||||
<WorkspaceNavChatList
|
|
||||||
showCollapseTrigger={compactChrome}
|
|
||||||
compact={compactChrome}
|
|
||||||
/>
|
|
||||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
|
||||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
|
||||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
|
||||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
|
||||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
|
||||||
</SidebarContent>
|
|
||||||
{!compactChrome && (
|
|
||||||
<SidebarFooter>
|
|
||||||
<WorkspaceNavMenu />
|
|
||||||
</SidebarFooter>
|
|
||||||
)}
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,20 +8,13 @@ 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 = BuiltinThemeId | `custom-theme-${string}`;
|
export type ThemeType = 'dark' | 'light' | 'lightblue';
|
||||||
|
|
||||||
// 主题配置
|
// 主题配置
|
||||||
export interface ThemeConfig {
|
export interface ThemeConfig {
|
||||||
type: BuiltinThemeId;
|
type: ThemeType;
|
||||||
name: string;
|
name: string;
|
||||||
colors: {
|
colors: {
|
||||||
background: string;
|
background: string;
|
||||||
@@ -44,7 +37,7 @@ export interface ThemeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 主题配置
|
// 主题配置
|
||||||
const themes: Record<BuiltinThemeId, ThemeConfig> = {
|
const themes: Record<ThemeType, ThemeConfig> = {
|
||||||
dark: {
|
dark: {
|
||||||
type: 'dark',
|
type: 'dark',
|
||||||
name: '深色主题',
|
name: '深色主题',
|
||||||
@@ -111,74 +104,6 @@ const themes: Record<BuiltinThemeId, 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';
|
||||||
@@ -189,30 +114,13 @@ 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 (isBuiltinTheme(saved)) return saved;
|
if (saved === 'dark') return 'dark';
|
||||||
if (isCustomThemeId(saved)) return saved;
|
if (saved === 'lightblue') return 'lightblue';
|
||||||
return 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,9 +161,7 @@ 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);
|
||||||
@@ -269,7 +175,12 @@ 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 (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
|
if (
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -300,47 +211,19 @@ 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 persistedTheme = readPersistedTheme();
|
const theme: ThemeType = mounted
|
||||||
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
|
? effectiveTheme === 'dark'
|
||||||
const customTheme = getEnabledCustomTheme(
|
? 'dark'
|
||||||
requestedTheme,
|
: effectiveTheme === 'lightblue'
|
||||||
appearance?.custom_themes,
|
? 'lightblue'
|
||||||
);
|
: 'light'
|
||||||
const theme: ThemeType = isBuiltinTheme(requestedTheme)
|
: readPersistedTheme();
|
||||||
? 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');
|
||||||
@@ -351,17 +234,12 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
root.removeAttribute('theme-mode');
|
root.removeAttribute('theme-mode');
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
|
|
||||||
if (
|
if (theme === 'lightblue') {
|
||||||
theme === 'lightblue' ||
|
root.setAttribute('data-theme', 'lightblue');
|
||||||
theme === 'lightgreen' ||
|
|
||||||
theme === 'iceblue' ||
|
|
||||||
theme === 'lightyellow'
|
|
||||||
) {
|
|
||||||
root.setAttribute('data-theme', theme);
|
|
||||||
} else {
|
} else {
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
}
|
}
|
||||||
}, [customTheme, theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
@@ -373,7 +251,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
markExplicitChoice();
|
markExplicitChoice();
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
|
const themeConfig = themes[theme];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
@@ -399,20 +277,6 @@ 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,15 +150,12 @@ export function useAllAgentsAdmin(enabled: boolean) {
|
|||||||
return { agents: data ?? [], isLoading, error };
|
return { agents: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgent(
|
export function useAgent(id: string | null | undefined) {
|
||||||
id: string | null | undefined,
|
const { data, isLoading, error } = useQuery({
|
||||||
{ enabled = true }: { enabled?: boolean } = {},
|
queryKey: ["agents", id],
|
||||||
) {
|
queryFn: () => getAgent(id!),
|
||||||
const { data, isLoading, error } = useQuery({
|
enabled: !!id,
|
||||||
queryKey: ["agents", id],
|
});
|
||||||
queryFn: () => getAgent(id!),
|
|
||||||
enabled: enabled && !!id,
|
|
||||||
});
|
|
||||||
return { agent: data ?? null, isLoading, error };
|
return { agent: data ?? null, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,32 +19,12 @@ export async function loadArtifactContent({
|
|||||||
let enhancedFilepath = filepath;
|
let enhancedFilepath = filepath;
|
||||||
if (filepath.endsWith(".skill")) {
|
if (filepath.endsWith(".skill")) {
|
||||||
enhancedFilepath = filepath + "/SKILL.md";
|
enhancedFilepath = filepath + "/SKILL.md";
|
||||||
}
|
}
|
||||||
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) {
|
return { content: text, url };
|
||||||
let detail = text.trim();
|
}
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
|
|
||||||
const parsedDetail =
|
|
||||||
typeof parsed.detail === "string"
|
|
||||||
? parsed.detail
|
|
||||||
: typeof parsed.message === "string"
|
|
||||||
? parsed.message
|
|
||||||
: "";
|
|
||||||
if (parsedDetail) {
|
|
||||||
detail = parsedDetail;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep the raw response text as the diagnostic detail.
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { content: text, url };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadArtifactContentFromToolCall({
|
export function loadArtifactContentFromToolCall({
|
||||||
url: urlString,
|
url: urlString,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
import type { AgentThread } from "../threads";
|
import type { AgentThread } from "../threads";
|
||||||
|
|
||||||
export function urlOfArtifact({
|
export function urlOfArtifact({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
download = false,
|
download = false,
|
||||||
isMock = false,
|
isMock = false,
|
||||||
publicShareCode,
|
publicShareCode,
|
||||||
}: {
|
}: {
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -16,24 +16,22 @@ export function urlOfArtifact({
|
|||||||
}) {
|
}) {
|
||||||
// A public read-only share has no auth-gated thread route — its referenced
|
// A public read-only share has no auth-gated thread route — its referenced
|
||||||
// files are served by the unauthenticated public-share endpoint instead.
|
// files are served by the unauthenticated public-share endpoint instead.
|
||||||
if (publicShareCode) {
|
if (publicShareCode) {
|
||||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||||
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) {
|
||||||
return thread.values.artifacts ?? [];
|
return thread.values.artifacts ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||||
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
|
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
||||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
|
}
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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] ?? "";
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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,7 +1,3 @@
|
|||||||
import { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
|
||||||
|
|
||||||
export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
|
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
|
||||||
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
|
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
|
||||||
@@ -43,8 +39,6 @@ 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` 只想要
|
||||||
@@ -81,11 +75,6 @@ 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");
|
||||||
@@ -96,6 +85,16 @@ 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();
|
||||||
@@ -111,7 +110,6 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -123,7 +121,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.hideHeader && !next.hideChrome && !next.isolate) {
|
if (!next.embed && !next.hideJump && !next.hideChrome && !next.isolate) {
|
||||||
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,16 +141,6 @@ 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」之并——任一置位都隐藏。
|
||||||
@@ -163,10 +151,6 @@ 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`,
|
||||||
@@ -184,14 +168,7 @@ 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);
|
||||||
@@ -206,10 +183,6 @@ 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;
|
||||||
@@ -261,13 +234,6 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* @typedef {"light" | "dark"} EmbedThemeMode
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map external theme names to the two themes supported by iframe surfaces.
|
|
||||||
*
|
|
||||||
* @param {string | null | undefined} rawTheme
|
|
||||||
* @returns {EmbedThemeMode}
|
|
||||||
*/
|
|
||||||
export function resolveEmbedThemeMode(rawTheme) {
|
|
||||||
const theme = (rawTheme ?? "").trim().toLowerCase();
|
|
||||||
return theme === "dark" ||
|
|
||||||
theme === "dark-blue" ||
|
|
||||||
theme === "深色" ||
|
|
||||||
theme === "深蓝"
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
}
|
|
||||||
@@ -448,17 +448,11 @@ export const enUS: Translations = {
|
|||||||
system: "System",
|
system: "System",
|
||||||
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.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -383,17 +383,11 @@ export interface Translations {
|
|||||||
system: string;
|
system: string;
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -429,17 +429,11 @@ export const zhCN: Translations = {
|
|||||||
system: "系统",
|
system: "系统",
|
||||||
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,13 +2,8 @@ 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", () => {
|
||||||
@@ -36,164 +31,3 @@ 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,141 +1,16 @@
|
|||||||
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 === CHATS_IFRAME_PATH ||
|
pathname === "/page/workspace/chats_iframe/new" ||
|
||||||
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,
|
||||||
search = "",
|
): "light" | undefined {
|
||||||
): "light" | "dark" | undefined {
|
if (pathname === "/landing" || isChatsIframeWorkspacePath(pathname)) {
|
||||||
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,12 +65,6 @@ 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: "大屏绘制",
|
||||||
|
|||||||
@@ -187,4 +187,4 @@ test("unmapped incoming url resets auto-selected agent back to default when ther
|
|||||||
forceDefaultAssistant: true,
|
forceDefaultAssistant: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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,40 +276,7 @@ export async function updatePublishSquares(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILTIN_THEME_IDS = [
|
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||||
"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";
|
||||||
@@ -337,7 +304,6 @@ 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 {
|
||||||
@@ -346,7 +312,6 @@ 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> {
|
||||||
@@ -504,16 +469,12 @@ 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,13 +111,10 @@ 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)
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
import type {
|
|
||||||
CustomThemeColorOverrides,
|
|
||||||
CustomThemePreset,
|
|
||||||
} from "@/core/system-settings/api";
|
|
||||||
|
|
||||||
export const CUSTOM_THEME_PREFIX = "custom-theme-";
|
|
||||||
|
|
||||||
type Rgb = { r: number; g: number; b: number };
|
|
||||||
|
|
||||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
|
||||||
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
|
|
||||||
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
|
|
||||||
|
|
||||||
export const CUSTOM_THEME_ADVANCED_KEYS = [
|
|
||||||
"button_hover",
|
|
||||||
"selected_background",
|
|
||||||
"border",
|
|
||||||
"muted",
|
|
||||||
"muted_text",
|
|
||||||
"destructive",
|
|
||||||
"brand_title",
|
|
||||||
"primary_foreground",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
|
|
||||||
|
|
||||||
export type CustomThemeTokenInput = Pick<
|
|
||||||
CustomThemePreset,
|
|
||||||
"primary" | "mode"
|
|
||||||
> &
|
|
||||||
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
|
|
||||||
|
|
||||||
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
|
|
||||||
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
|
|
||||||
const normalized = value.trim().toUpperCase();
|
|
||||||
return HEX_COLOR.test(normalized) ? normalized : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalHexColor(value: string | null | undefined): string | undefined {
|
|
||||||
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
|
|
||||||
return value.trim().toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRgb(value: string): Rgb {
|
|
||||||
const hex = normalizeHexColor(value).slice(1);
|
|
||||||
return {
|
|
||||||
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
||||||
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
||||||
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHex({ r, g, b }: Rgb): string {
|
|
||||||
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
|
|
||||||
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
|
|
||||||
const ratio = Math.min(1, Math.max(0, amount));
|
|
||||||
return {
|
|
||||||
r: from.r + (to.r - from.r) * ratio,
|
|
||||||
g: from.g + (to.g - from.g) * ratio,
|
|
||||||
b: from.b + (to.b - from.b) * ratio,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativeLuminance({ r, g, b }: Rgb): number {
|
|
||||||
const linear = [r, g, b].map((channel) => {
|
|
||||||
const value = channel / 255;
|
|
||||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
||||||
});
|
|
||||||
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contrastRatio(a: Rgb, b: Rgb): number {
|
|
||||||
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
|
|
||||||
return (lighter! + 0.05) / (darker! + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readableForeground(background: Rgb): string {
|
|
||||||
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
|
|
||||||
let candidate = color;
|
|
||||||
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
|
|
||||||
candidate = mix(candidate, toward, 0.18);
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rgbChannels(color: Rgb): string {
|
|
||||||
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
|
|
||||||
* Inputs are restricted to a safe six-digit primary colour, optional primary
|
|
||||||
* button text colour, a small set of interaction overrides and a light/dark surface
|
|
||||||
* family, so callers never interpolate arbitrary CSS values.
|
|
||||||
*/
|
|
||||||
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
|
|
||||||
const primary = toRgb(theme.primary);
|
|
||||||
const isDark = theme.mode === "dark";
|
|
||||||
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
|
|
||||||
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
|
|
||||||
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
|
|
||||||
// Normal page copy stays automatic so selecting white for a teal/blue
|
|
||||||
// action does not accidentally make a light-page's body text white.
|
|
||||||
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
|
|
||||||
const foregroundRgb = ensureContrast(
|
|
||||||
preferredForeground,
|
|
||||||
background,
|
|
||||||
toRgb(readableForeground(background)),
|
|
||||||
);
|
|
||||||
const foreground = toHex(foregroundRgb);
|
|
||||||
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
|
|
||||||
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
|
|
||||||
? ensureContrast(
|
|
||||||
toRgb(overrides.muted_text!),
|
|
||||||
background,
|
|
||||||
toRgb(readableForeground(background)),
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
: defaultMutedForeground;
|
|
||||||
const mutedForeground = toHex(mutedForegroundRgb);
|
|
||||||
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
|
|
||||||
const accent = optionalHexColor(overrides.selected_background)
|
|
||||||
? toRgb(overrides.selected_background!)
|
|
||||||
: defaultAccent;
|
|
||||||
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
|
|
||||||
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
|
|
||||||
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
|
|
||||||
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
|
|
||||||
const action = ensureContrast(
|
|
||||||
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
|
|
||||||
isDark ? background : WHITE,
|
|
||||||
isDark ? WHITE : BLACK,
|
|
||||||
);
|
|
||||||
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
|
|
||||||
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
|
|
||||||
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
|
|
||||||
const primaryHover = optionalHexColor(overrides.button_hover)
|
|
||||||
? toRgb(overrides.button_hover!)
|
|
||||||
: defaultPrimaryHover;
|
|
||||||
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
|
|
||||||
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
|
|
||||||
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
|
|
||||||
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
|
|
||||||
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
|
|
||||||
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
|
|
||||||
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
|
|
||||||
const ring = rgbChannels(primary);
|
|
||||||
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
|
|
||||||
const primaryHex = toHex(primary);
|
|
||||||
const actionHex = toHex(action);
|
|
||||||
const actionHoverHex = toHex(actionHover);
|
|
||||||
const actionActiveHex = toHex(actionActive);
|
|
||||||
const accentHex = toHex(accent);
|
|
||||||
const accentHoverHex = toHex(accentHover);
|
|
||||||
const backgroundHex = toHex(background);
|
|
||||||
const cardHex = toHex(card);
|
|
||||||
const borderHex = toHex(border);
|
|
||||||
const inputHex = toHex(input);
|
|
||||||
const secondaryHex = toHex(secondary);
|
|
||||||
const primaryHoverHex = toHex(primaryHover);
|
|
||||||
const primaryActiveHex = toHex(primaryActive);
|
|
||||||
const sidebarHex = toHex(sidebar);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"--workspace-brand": primaryHex,
|
|
||||||
"--workspace-brand-foreground": primaryForeground,
|
|
||||||
"--brand-200": accentHex,
|
|
||||||
"--brand-900": actionHex,
|
|
||||||
"--brand-950": actionActiveHex,
|
|
||||||
"--theme-button-hover": primaryHoverHex,
|
|
||||||
"--theme-selected-background": accentHex,
|
|
||||||
"--background": backgroundHex,
|
|
||||||
"--foreground": foreground,
|
|
||||||
"--card": cardHex,
|
|
||||||
"--card-foreground": foreground,
|
|
||||||
"--popover": cardHex,
|
|
||||||
"--popover-foreground": foreground,
|
|
||||||
"--primary": primaryHex,
|
|
||||||
"--primary-foreground": primaryForeground,
|
|
||||||
"--button-foreground": buttonForeground,
|
|
||||||
"--brand-title-foreground": brandTitle,
|
|
||||||
"--secondary": secondaryHex,
|
|
||||||
"--secondary-foreground": actionHex,
|
|
||||||
"--muted": secondaryHex,
|
|
||||||
"--muted-foreground": mutedForeground,
|
|
||||||
"--accent": accentHex,
|
|
||||||
"--accent-foreground": actionHex,
|
|
||||||
"--destructive": destructive,
|
|
||||||
"--border": borderHex,
|
|
||||||
"--input": inputHex,
|
|
||||||
"--ring": `rgb(${ring} / 0.48)`,
|
|
||||||
"--chart-1": actionHex,
|
|
||||||
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
|
|
||||||
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
|
|
||||||
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
|
|
||||||
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
|
|
||||||
"--sidebar": sidebarHex,
|
|
||||||
"--sidebar-foreground": foreground,
|
|
||||||
"--sidebar-primary": primaryHex,
|
|
||||||
"--sidebar-primary-foreground": primaryForeground,
|
|
||||||
"--sidebar-accent": accentHex,
|
|
||||||
"--sidebar-accent-foreground": actionHex,
|
|
||||||
"--sidebar-border": borderHex,
|
|
||||||
"--sidebar-ring": `rgb(${ring} / 0.48)`,
|
|
||||||
"--legacy-accent-rgb": rgbChannels(accent),
|
|
||||||
"--legacy-background-rgb": rgbChannels(background),
|
|
||||||
"--legacy-border-rgb": rgbChannels(border),
|
|
||||||
"--legacy-brand-200-rgb": rgbChannels(accent),
|
|
||||||
"--legacy-card-rgb": rgbChannels(card),
|
|
||||||
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
|
|
||||||
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
|
|
||||||
"--legacy-input-rgb": rgbChannels(input),
|
|
||||||
"--legacy-muted-rgb": rgbChannels(secondary),
|
|
||||||
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
|
|
||||||
"--legacy-primary-rgb": rgbChannels(primary),
|
|
||||||
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
|
|
||||||
"--legacy-ring-rgb": ring,
|
|
||||||
"--legacy-secondary-rgb": rgbChannels(secondary),
|
|
||||||
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
|
|
||||||
"--legacy-sidebar-border-rgb": rgbChannels(border),
|
|
||||||
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
|
|
||||||
"--legacy-workspace-brand-rgb": rgbChannels(primary),
|
|
||||||
"--td-brand-color-1": backgroundHex,
|
|
||||||
"--td-brand-color-2": secondaryHex,
|
|
||||||
"--td-brand-color-3": accentHex,
|
|
||||||
"--td-brand-color-4": accentHoverHex,
|
|
||||||
"--td-brand-color-5": primaryHex,
|
|
||||||
"--td-brand-color-6": primaryHex,
|
|
||||||
"--td-brand-color-7": actionHex,
|
|
||||||
"--td-brand-color-8": actionHoverHex,
|
|
||||||
"--td-brand-color-9": actionActiveHex,
|
|
||||||
"--td-brand-color-10": actionActiveHex,
|
|
||||||
"--td-brand-color": primaryHex,
|
|
||||||
"--td-brand-color-hover": primaryHoverHex,
|
|
||||||
"--td-brand-color-focus": accentHex,
|
|
||||||
"--td-brand-color-active": primaryActiveHex,
|
|
||||||
"--td-brand-color-disabled": accentHoverHex,
|
|
||||||
"--td-brand-color-light": accentHex,
|
|
||||||
"--td-brand-color-light-hover": accentHoverHex,
|
|
||||||
"--td-text-color-primary": foreground,
|
|
||||||
"--td-text-color-secondary": mutedForeground,
|
|
||||||
"--td-text-color-brand": actionHex,
|
|
||||||
"--td-text-color-link": actionHex,
|
|
||||||
"--td-text-color-anti": buttonForeground,
|
|
||||||
"--td-bg-color-page": backgroundHex,
|
|
||||||
"--td-bg-color-container": cardHex,
|
|
||||||
"--td-bg-color-container-hover": secondaryHex,
|
|
||||||
"--td-bg-color-container-active": accentHex,
|
|
||||||
"--td-bg-color-secondarycontainer": secondaryHex,
|
|
||||||
"--td-bg-color-secondarycontainer-hover": accentHex,
|
|
||||||
"--td-bg-color-secondarycontainer-active": accentHoverHex,
|
|
||||||
"--td-border-level-1-color": borderHex,
|
|
||||||
"--td-component-stroke": borderHex,
|
|
||||||
"--td-border-level-2-color": inputHex,
|
|
||||||
"--td-component-border": inputHex,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tokens produced from the automatic palette only, used by the editor to
|
|
||||||
* show which advanced values are currently inheriting from the system. */
|
|
||||||
export function getCustomThemeAutomaticTokens(
|
|
||||||
theme: Pick<CustomThemePreset, "primary" | "mode">,
|
|
||||||
): Record<string, string> {
|
|
||||||
return getCustomThemeTokens(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function customThemeContrast(primary: string): number {
|
|
||||||
const color = toRgb(primary);
|
|
||||||
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
|
|
||||||
const tokens = getCustomThemeTokens(theme);
|
|
||||||
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
|
|
||||||
}
|
|
||||||
@@ -30,20 +30,11 @@ export type ToolEndEvent = {
|
|||||||
data: unknown;
|
data: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
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>;
|
||||||
@@ -54,45 +45,11 @@ 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) {
|
||||||
@@ -225,22 +182,6 @@ 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,
|
||||||
@@ -322,24 +263,11 @@ 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) {
|
||||||
@@ -443,10 +371,9 @@ function lastHumanFromChatModelInput(input: unknown): string {
|
|||||||
export function useThreadStream({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
assistantId,
|
assistantId,
|
||||||
runOptions,
|
threadMetadata,
|
||||||
threadMetadata,
|
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
@@ -457,14 +384,8 @@ export function useThreadStream({
|
|||||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||||
// 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,
|
||||||
@@ -496,14 +417,13 @@ 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();
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
@@ -527,8 +447,8 @@ export function useThreadStream({
|
|||||||
? () => runMetadataStorageRef.current!
|
? () => runMetadataStorageRef.current!
|
||||||
: false,
|
: false,
|
||||||
fetchStateHistory: { limit: 1 },
|
fetchStateHistory: { limit: 1 },
|
||||||
onCreated(meta) {
|
onCreated(meta) {
|
||||||
handleStreamStart(meta.thread_id);
|
handleStreamStart(meta.thread_id);
|
||||||
setOnStreamThreadId(meta.thread_id);
|
setOnStreamThreadId(meta.thread_id);
|
||||||
const metadataAgentId = context.agent_id ?? context.agent_name;
|
const metadataAgentId = context.agent_id ?? context.agent_name;
|
||||||
const extraMetadata =
|
const extraMetadata =
|
||||||
@@ -542,10 +462,9 @@ export function useThreadStream({
|
|||||||
.threads.update(meta.thread_id, { metadata })
|
.threads.update(meta.thread_id, { metadata })
|
||||||
.catch(() => ({}));
|
.catch(() => ({}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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,
|
||||||
data: event.data,
|
data: event.data,
|
||||||
@@ -573,10 +492,9 @@ 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 || {},
|
||||||
);
|
);
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
@@ -597,10 +515,9 @@ export function useThreadStream({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCustomEvent(event: unknown) {
|
onCustomEvent(event: unknown) {
|
||||||
markStreamActivity();
|
if (
|
||||||
if (
|
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
event !== null &&
|
event !== null &&
|
||||||
"type" in event &&
|
"type" in event &&
|
||||||
@@ -644,10 +561,9 @@ export function useThreadStream({
|
|||||||
const e = event as { type: "context_compacting"; message: string };
|
const e = event as { type: "context_compacting"; message: string };
|
||||||
toast(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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:
|
||||||
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
|
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
|
||||||
@@ -697,20 +613,22 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
// 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是
|
||||||
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
|
// 把原始的英文/HTTP 错误抛给用户。
|
||||||
const friendly = friendlySendLimitMessage(message);
|
if (isActiveRunConflictError(message)) {
|
||||||
if (friendly) {
|
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
|
||||||
toast.info(friendly);
|
return;
|
||||||
|
}
|
||||||
|
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"] });
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimistic messages shown before the server stream responds
|
// Optimistic messages shown before the server stream responds
|
||||||
@@ -733,97 +651,8 @@ export function useThreadStream({
|
|||||||
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
||||||
// an in-flight stream without re-running on every render that produces a
|
// an in-flight stream without re-running on every render that produces a
|
||||||
// new `thread` identity.
|
// new `thread` identity.
|
||||||
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
|
||||||
@@ -1136,51 +965,32 @@ export function useThreadStream({
|
|||||||
...extraContext,
|
...extraContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
await thread.submit(
|
||||||
const submittedMessages: Message[] = [
|
{
|
||||||
...hiddenContextMessages.map((item) => ({
|
messages: [
|
||||||
type: "human" as const,
|
{
|
||||||
...(item.name ? { name: item.name } : {}),
|
type: "human",
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text",
|
||||||
text: item.text,
|
text,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
additional_kwargs: {
|
additional_kwargs: {
|
||||||
...item.additionalKwargs,
|
...options?.additionalKwargs,
|
||||||
hide_from_ui: true,
|
client_ts: clientTs,
|
||||||
},
|
...(filesForSubmit.length > 0
|
||||||
})),
|
? { files: filesForSubmit }
|
||||||
{
|
: {}),
|
||||||
type: "human" as const,
|
},
|
||||||
content: [
|
},
|
||||||
{
|
],
|
||||||
type: "text" as const,
|
},
|
||||||
text,
|
{
|
||||||
},
|
threadId: threadId,
|
||||||
],
|
streamSubgraphs: true,
|
||||||
additional_kwargs: {
|
streamResumable: true,
|
||||||
...options?.additionalKwargs,
|
config: {
|
||||||
client_ts: clientTs,
|
|
||||||
...(filesForSubmit.length > 0
|
|
||||||
? { files: filesForSubmit }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await thread.submit(
|
|
||||||
{
|
|
||||||
messages: submittedMessages,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threadId: threadId,
|
|
||||||
streamSubgraphs: true,
|
|
||||||
streamResumable: true,
|
|
||||||
onDisconnect: runOptions?.onDisconnect,
|
|
||||||
multitaskStrategy: runOptions?.multitaskStrategy,
|
|
||||||
config: {
|
|
||||||
recursion_limit: 1000,
|
recursion_limit: 1000,
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
@@ -1207,35 +1017,15 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
setOptimisticMessages([]);
|
||||||
error instanceof Error ? error.message : String(error);
|
setIsUploading(false);
|
||||||
const friendly = friendlySendLimitMessage(message);
|
throw error;
|
||||||
if (friendly) {
|
} finally {
|
||||||
setOptimisticMessages((messages) => {
|
|
||||||
const human = messages.find((item) => item.type === "human");
|
|
||||||
const kept = human ? [human] : [];
|
|
||||||
return [
|
|
||||||
...kept,
|
|
||||||
{
|
|
||||||
type: "ai",
|
|
||||||
id: `opt-ai-limit-${Date.now()}`,
|
|
||||||
content: friendly,
|
|
||||||
additional_kwargs: { element: "task" },
|
|
||||||
} as Message,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
setIsUploading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOptimisticMessages([]);
|
|
||||||
setIsUploading(false);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
sendInFlightRef.current = false;
|
sendInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
|
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge thread with optimistic messages for display
|
// Merge thread with optimistic messages for display
|
||||||
@@ -1510,8 +1300,8 @@ export function useDeleteThread() {
|
|||||||
* `MessageList`) so the removal shows immediately; a reload then reads the
|
* `MessageList`) so the removal shows immediately; a reload then reads the
|
||||||
* persisted, already-trimmed state.
|
* persisted, already-trimmed state.
|
||||||
*/
|
*/
|
||||||
export function useDeleteMessage() {
|
export function useDeleteMessage() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
threadId,
|
threadId,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -1541,92 +1331,12 @@ export function useDeleteMessage() {
|
|||||||
// Fall back to the requested id so the caller can always hide something.
|
// Fall back to the requested id so the caller can always hide something.
|
||||||
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
|
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all messages from a thread checkpoint. The caller should immediately
|
* Merge metadata into a conversation's record via the Gateway
|
||||||
* hide the returned ids locally because the active stream cache may still hold
|
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
||||||
* the old message objects until a reload/refetch reads the trimmed checkpoint.
|
|
||||||
*/
|
|
||||||
export function useClearThreadMessages() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
threadId,
|
|
||||||
}: {
|
|
||||||
threadId: string;
|
|
||||||
}): Promise<string[]> => {
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
|
||||||
threadId,
|
|
||||||
)}/messages`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "清空对话失败。" }));
|
|
||||||
throw new Error(error.detail ?? "清空对话失败。");
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.json().catch(() => ({}))) as {
|
|
||||||
deleted_ids?: string[];
|
|
||||||
};
|
|
||||||
return body.deleted_ids ?? [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist an in-place message edit in the latest thread checkpoint.
|
|
||||||
*
|
|
||||||
* This is opt-in UI behaviour: most chat pages keep their existing
|
|
||||||
* "edit question and resubmit" affordance, while position roundtable can pass
|
|
||||||
* this mutation down to save the original message content itself.
|
|
||||||
*/
|
|
||||||
export function useEditMessage() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
threadId,
|
|
||||||
messageId,
|
|
||||||
content,
|
|
||||||
}: {
|
|
||||||
threadId: string;
|
|
||||||
messageId: string;
|
|
||||||
content: string;
|
|
||||||
}): Promise<Message> => {
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
|
||||||
threadId,
|
|
||||||
)}/messages/${encodeURIComponent(messageId)}`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "消息保存失败。" }));
|
|
||||||
throw new Error(error.detail ?? "消息保存失败。");
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.json().catch(() => ({}))) as {
|
|
||||||
message?: Message;
|
|
||||||
};
|
|
||||||
return body.message ?? ({ id: messageId, type: "human", content } as Message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge metadata into a conversation's record via the Gateway
|
|
||||||
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
|
||||||
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
|
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
|
||||||
* single user, so these live directly on the thread metadata (no join table).
|
* single user, so these live directly on the thread metadata (no join table).
|
||||||
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
|
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/* 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 步的任务理解面板与业务链流程图样式。 */
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/* 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; }
|
|
||||||
|
|
||||||
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/* 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,10 +29,5 @@ 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"),
|
||||||
};
|
};
|
||||||
@@ -2,10 +2,9 @@ import "@fontsource/noto-sans-sc/chinese-simplified-400.css";
|
|||||||
import "@fontsource/noto-sans-sc/chinese-simplified-500.css";
|
import "@fontsource/noto-sans-sc/chinese-simplified-500.css";
|
||||||
import "@fontsource/noto-sans-sc/chinese-simplified-600.css";
|
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";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||||
|
import {
|
||||||
export default function AgentsPage() {
|
WorkspaceBody,
|
||||||
return <AgentGallery />;
|
WorkspaceContainer,
|
||||||
}
|
} from "@/components/workspace/workspace-container";
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
return (
|
||||||
|
<WorkspaceContainer className="bg-white dark:bg-slate-950">
|
||||||
|
<WorkspaceBody>
|
||||||
|
<AgentGallery />
|
||||||
|
</WorkspaceBody>
|
||||||
|
</WorkspaceContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,9 +35,7 @@ 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";
|
||||||
@@ -52,7 +50,6 @@ 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";
|
||||||
@@ -101,16 +98,9 @@ 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 } =
|
||||||
@@ -450,22 +440,9 @@ 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;
|
||||||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
// 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。
|
||||||
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
|
const compactChrome = !isChatsIframeWorkspacePath(pathname);
|
||||||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 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 }}>
|
||||||
@@ -477,20 +454,16 @@ export default function ChatPage() {
|
|||||||
compactChrome && "workspace-chat-bg",
|
compactChrome && "workspace-chat-bg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header
|
{(!isLandingState || compactChrome) && (
|
||||||
className={cn(
|
<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">
|
||||||
"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">
|
||||||
<ThreadTitle threadId={threadId} thread={thread} />
|
{!isLandingState && (
|
||||||
|
<ThreadTitle threadId={threadId} thread={thread} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||||
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
|
{compactChrome && !historySidebarOpen && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -514,7 +487,16 @@ export default function ChatPage() {
|
|||||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||||
{!compactChrome && !compactActionsHidden && (
|
{compactChrome ? (
|
||||||
|
<>
|
||||||
|
<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" />
|
||||||
@@ -528,6 +510,7 @@ 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">
|
||||||
{/*
|
{/*
|
||||||
@@ -563,7 +546,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-center"
|
? "inset-0 items-start overflow-y-auto pt-[clamp(82px,16vh,146px)] pb-12"
|
||||||
: "right-0 bottom-0 left-0",
|
: "right-0 bottom-0 left-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -575,7 +558,7 @@ export default function ChatPage() {
|
|||||||
!compactLanding &&
|
!compactLanding &&
|
||||||
"-translate-y-[calc(50vh-96px)]",
|
"-translate-y-[calc(50vh-96px)]",
|
||||||
compactLanding
|
compactLanding
|
||||||
? "max-w-(--container-width-xl)"
|
? "max-w-[1024px]"
|
||||||
: isLandingState
|
: isLandingState
|
||||||
? "max-w-(--container-width-md)"
|
? "max-w-(--container-width-md)"
|
||||||
: "max-w-(--container-width-lg)",
|
: "max-w-(--container-width-lg)",
|
||||||
@@ -594,7 +577,7 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
{isLandingState && mounted &&
|
{isLandingState && mounted &&
|
||||||
(compactLanding ? (
|
(compactLanding ? (
|
||||||
<CompactWelcome className="mb-6 -translate-y-4" />
|
<CompactWelcome className="mb-14" />
|
||||||
) : (
|
) : (
|
||||||
<Welcome
|
<Welcome
|
||||||
mode={settings.context.mode}
|
mode={settings.context.mode}
|
||||||
@@ -602,7 +585,7 @@ export default function ChatPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<div className="w-full -translate-y-4">
|
<div className={cn("w-full", !compactLanding && "-translate-y-4")}>
|
||||||
<InputBox
|
<InputBox
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread={isLandingState}
|
isNewThread={isLandingState}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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,
|
||||||
@@ -185,7 +184,6 @@ 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,14 +2,12 @@
|
|||||||
|
|
||||||
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";
|
||||||
@@ -27,13 +25,12 @@ 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, useAgentsPaginated } from "@/core/agents";
|
import { useAgentSelector } 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,
|
||||||
@@ -45,7 +42,6 @@ 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,
|
||||||
@@ -130,8 +126,6 @@ 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 } =
|
||||||
@@ -147,34 +141,6 @@ 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<
|
||||||
@@ -264,11 +230,7 @@ export default function IframeChatPage() {
|
|||||||
history.replaceState(
|
history.replaceState(
|
||||||
null,
|
null,
|
||||||
"",
|
"",
|
||||||
`${window.location.pathname}${window.location.search}#${buildChatsIframeThreadPath(
|
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
|
||||||
routeBase,
|
|
||||||
createdThreadId,
|
|
||||||
location.search,
|
|
||||||
)}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pendingRequestIdRef.current) {
|
if (pendingRequestIdRef.current) {
|
||||||
@@ -383,30 +345,6 @@ 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]);
|
||||||
@@ -705,46 +643,15 @@ 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 px-4 text-center">
|
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||||
<div className="text-2xl font-medium">你好</div>
|
<div className="text-2xl font-medium">你好</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
<div className="mt-4">
|
||||||
选择智能体进入问答
|
<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" && (
|
||||||
|
|||||||