diff --git a/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md b/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md new file mode 100644 index 00000000..e652e565 --- /dev/null +++ b/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md @@ -0,0 +1,375 @@ +# 浅蓝色 UI 规范 + +> 本文档由设计稿截图(`1.1 全局样式-色彩`、`1.2 全局样式-色彩附录`)识别整理而成。 +> 全部色值已与"色彩附录"编号表逐一核对,保证准确。 + +--- + +## 1.1 色彩体系总览 + +调色体系分为 **主色** 与 **辅色** 两大类。每个色相按用途分为若干阶梯: + +| 阶梯 | 数量 | 用途 | +| --- | --- | --- | +| 浅色 | 3 | 用于大面积底色 | +| 中亮色 | 6 | 用于标签、图表、提示等 | +| 深色 | 4(+基准色,共 5 个深阶) | 用于小面积按钮、图标等 | +| 线性渐变色 | 1 | 用于特殊装饰性小面积底色 | +| 径向渐变色 | 1 | 用于特殊装饰性小面积底色 | + +> 中性色(灰阶)略有不同:浅色用于背景 / 白色文字及分割线,中亮色用于备注文字及图标,深色用于正文,且**无渐变色**。 + +--- + +## 主色 + +### 主色 - 红(基准 `#CF1322`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FEFAFA` · `#FEF6F6` · `#FEEDED` | +| 中亮色 | `#FDDADC` · `#FCB5B9` · `#FA9196` · `#F86C73` · `#F74750` · `#F5222D` | +| 深色 | `#CF1322` · `#BC0D1E` · `#A8071A` · `#820014` · `#5C0011` | +| 线性渐变色 | `#F5222D` → `#A8071A` | +| 径向渐变色 | `#F86C73` → `#F5222D` | + +### 主色 - 蓝(基准 `#0062D9`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#F9FCFF` · `#F4F9FF` · `#EAF4FF` | +| 中亮色 | `#D4E9FF` · `#AAD2FF` · `#80BCFF` · `#55A6FE` · `#2B8FFE` · `#0079FE` | +| 深色 | `#0062D9` · `#0056C6` · `#004AB3` · `#00368C` · `#002466` | +| 线性渐变色 | `#0079FE` → `#004AB3` | +| 径向渐变色 | `#55A6FE` → `#0079FE` | + +--- + +## 辅色 + +### 辅色 - 橙(基准 `#D97B00`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FFFCF9` · `#FFFAF4` · `#FFF6EA` | +| 中亮色 | `#FFEED4` · `#FFDDAA` · `#FFCC80` · `#FFBB55` · `#FFAA2B` · `#FF9900` | +| 深色 | `#D97B00` · `#C66D00` · `#B35F00` · `#8C4600` · `#663000` | +| 线性渐变色 | `#FF9900` → `#B35F00` | +| 径向渐变色 | `#FFBB55` → `#FF9900` | + +### 辅色 - 绿(基准 `#1EB07F`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FAFEFD` · `#F6FDFB` · `#EEFCF7` | +| 中亮色 | `#DCF8EE` · `#BAF1DD` · `#97EBCD` · `#74E4BC` · `#52DDAB` · `#2FD69A` | +| 深色 | `#1EB07F` · `#189D72` · `#118A65` · `#07634B` · `#043D30` | +| 线性渐变色 | `#2FD69A` → `#118A65` | +| 径向渐变色 | `#74E4BC` → `#2FD69A` | + +### 辅色 - 青蓝(基准 `#2F9ACC`) + +| 用途 | 色值 | +| --- | --- | +| 浅色 | `#FBFDFE` · `#F7FCFE` · `#EFFAFE` | +| 中亮色 | `#E0F5FD` · `#C1EBFB` · `#A2E1F9` · `#83D7F7` · `#64CDF5` · `#45C3F3` | +| 深色 | `#2F9ACC` · `#2788B9` · `#1E76A6` · `#115580` · `#0B3959` | +| 线性渐变色 | `#45C3F3` → `#1E76A6` | +| 径向渐变色 | `#83D7F7` → `#45C3F3` | + +### 中性色 - 灰(基准 `#222222`) + +| 用途 | 色值 | +| --- | --- | +| 浅色(背景、白色文字及分割线) | `#FFFFFF` · `#FAFAFA` · `#F5F5F5` | +| 中亮色(备注文字及图标) | `#E8E8E8` · `#DBDBDB` · `#C1C1C1` · `#A6A6A6` · `#7F7F7F` · `#575757` | +| 深色(正文) | `#3D3D3D` · `#222222` | + +--- + +## 1.2 色彩附录(编号对照表) + +编号规则:`ZS` = 主色(主色调),`FS` = 辅色 / 中性色。`-01~-03` 为浅色,`-04~-09` 为中亮色,`-10~-13` 为深色,`-14` 为线性渐变色,`-15` 为径向渐变色;不带后缀的为该色相基准深色。 + +### 主色 ZS + +| 编号 | 红 (ZS-001) | 编号 | 蓝 (ZS-002) | +| --- | --- | --- | --- | +| ZS-001 | `#CF1322` | ZS-002 | `#0062D9` | +| ZS-001-01 | `#FEFAFA` | ZS-002-01 | `#F9FCFF` | +| ZS-001-02 | `#FEF6F6` | ZS-002-02 | `#F4F9FF` | +| ZS-001-03 | `#FEEDED` | ZS-002-03 | `#EAF4FF` | +| ZS-001-04 | `#FDDADC` | ZS-002-04 | `#D4E9FF` | +| ZS-001-05 | `#FCB5B9` | ZS-002-05 | `#AAD2FF` | +| ZS-001-06 | `#FA9196` | ZS-002-06 | `#80BCFF` | +| ZS-001-07 | `#F86C73` | ZS-002-07 | `#55A6FE` | +| ZS-001-08 | `#F74750` | ZS-002-08 | `#2B8FFE` | +| ZS-001-09 | `#F5222D` | ZS-002-09 | `#0079FE` | +| ZS-001-10 | `#BC0D1E` | ZS-002-10 | `#0056C6` | +| ZS-001-11 | `#A8071A` | ZS-002-11 | `#004AB3` | +| ZS-001-12 | `#820014` | ZS-002-12 | `#00368C` | +| ZS-001-13 | `#5C0011` | ZS-002-13 | `#002466` | +| ZS-001-14 | `#F5222D` → `#A8071A` | ZS-002-14 | `#0079FE` → `#004AB3` | +| ZS-001-15 | `#F86C73` → `#F5222D` | ZS-002-15 | `#55A6FE` → `#0079FE` | + +### 辅色 / 中性色 FS + +| 编号 | 橙 (FS-001) | 绿 (FS-002) | 青蓝 (FS-003) | 中性灰 (FS-004) | +| --- | --- | --- | --- | --- | +| FS-00n | `#D97B00` | `#1EB07F` | `#2F9ACC` | `#222222` | +| -01 | `#FFFCF9` | `#FAFEFD` | `#FBFDFE` | `#FFFFFF` | +| -02 | `#FFFAF4` | `#F6FDFB` | `#F7FCFE` | `#FAFAFA` | +| -03 | `#FFF6EA` | `#EEFCF7` | `#EFFAFE` | `#F5F5F5` | +| -04 | `#FFEED4` | `#DCF8EE` | `#E0F5FD` | `#E8E8E8` | +| -05 | `#FFDDAA` | `#BAF1DD` | `#C1EBFB` | `#DBDBDB` | +| -06 | `#FFCC80` | `#97EBCD` | `#A2E1F9` | `#C1C1C1` | +| -07 | `#FFBB55` | `#74E4BC` | `#83D7F7` | `#A6A6A6` | +| -08 | `#FFAA2B` | `#52DDAB` | `#64CDF5` | `#7F7F7F` | +| -09 | `#FF9900` | `#2FD69A` | `#45C3F3` | `#575757` | +| -10 | `#C66D00` | `#189D72` | `#2788B9` | `#3D3D3D` | +| -11 | `#B35F00` | `#118A65` | `#1E76A6` | — | +| -12 | `#8C4600` | `#07634B` | `#115580` | — | +| -13 | `#663000` | `#043D30` | `#0B3959` | — | +| -14 | `#FF9900` → `#B35F00` | `#2FD69A` → `#118A65` | `#45C3F3` → `#1E76A6` | — | +| -15 | `#FFBB55` → `#FF9900` | `#74E4BC` → `#2FD69A` | `#83D7F7` → `#45C3F3` | — | + +> 注:中性灰(FS-004)无渐变阶,最深至 `#222222`(基准)/`#3D3D3D`(FS-004-10),用于正文文字。 + +--- + +# 组件规范 + +> 以下为各组件的视觉规范与实现方式。颜色统一引用上文色值,建议在项目中先抽取为 CSS 变量 / Tailwind token,组件只引用语义化变量。 + +## 语义化 Token 建议 + +把色彩体系映射成语义变量,组件层只用语义名,便于换肤与维护: + +```css +:root { + /* 品牌 / 主色(蓝) */ + --color-primary: #0062D9; /* 主按钮、实底选中:ZS-002 深色基准 */ + --color-primary-hover: #0079FE; /* 悬浮态:ZS-002-09 */ + --color-primary-active: #0056C6; /* 按下态:ZS-002-10 */ + --color-primary-bg: #EAF4FF; /* 浅底选中底色:ZS-002-03 */ + --color-primary-border: #0079FE; /* 描边选中边框 */ + + /* 中性 */ + --color-text: #3D3D3D; /* 正文:FS-004-10 */ + --color-text-secondary: #A6A6A6; /* 次要 / 占位符:FS-004-07 */ + --color-border: #DBDBDB; /* 默认边框:FS-004-05 */ + --color-bg-disabled: #F5F5F5; /* 失效底色:FS-004-03 */ + --color-fill-disabled: #A6A6A6; /* 主按钮失效底色:FS-004-07 */ + --color-surface: #FFFFFF; /* 卡片 / 控件底 */ +} +``` + +> 项目使用 Tailwind v4 + shadcn,可在 `@theme` 中声明这些变量后用 `bg-primary` / `text-primary` 等使用;下文示例同时给出等价的 Tailwind 写法。 + +--- + +## 2.1 基础 - 按钮(Button) + +### 按钮尺寸 + +| 规格 | 高度 | 圆角 | 用途 | +| --- | --- | --- | --- | +| 小 | **28px** | 4px | 空间小的板块内部,如表格中、卡片标题等 | +| 正常 | **32px** | 4px | 常用按钮(默认尺寸) | + +水平内边距约 12–16px,字号 14px,图标与文字间距 4–8px。 + +### 常用按钮类型 + +| 类型 | 视觉 | 用途 | +| --- | --- | --- | +| 主按钮 | 实底 `--color-primary`,白字 | 一个操作区域**只能有一个**主按钮 | +| 主按钮·失效 | 实底灰 `--color-fill-disabled`,白字 | 主按钮禁用态 | +| 次要按钮 | 白底 + `--color-primary` 边框 + 蓝字 | 重要度低于主按钮,高于默认按钮 | +| 默认按钮 | 白底 + `--color-border` 边框 + `--color-text` | 默认动作(取消等) | +| 默认·失效 | `--color-bg-disabled` 底 + 浅边框 + `--color-text-secondary` | 次要/默认按钮禁用态 | +| 文本按钮 | 无边框无底,蓝字(可加下划线) | 表格操作、文件下载等 | +| 图标按钮 | 仅图标,可选 描边/实底/浅底 × 方形/圆形 | 增强辨识度或节省空间 | + +实现(Tailwind 示意): + +```tsx +// 主按钮 + + +// 次要按钮(次按钮) + + +// 默认按钮 + + +// 文本按钮 + + +// 图标按钮(实底圆形) + +``` + +图标按钮三种底 × 两种形状: +- **描边**:白底 + `--color-border` 边框,图标用 `--color-text` +- **实底**:`--color-primary` 底 + 白图标 +- **浅底**:`--color-primary-bg`(`#EAF4FF`)底 + 蓝图标 +- 形状:方形 `rounded`(4px)/ 圆形 `rounded-full` + +### 按钮组合 + +| 组合 | 说明 | +| --- | --- | +| 文字 + 下拉按钮 | 按钮右侧带 `⌄` 箭头;有 默认 / 主按钮 两种变体 | +| 图标 + 文字按钮 | `+ 正常`,前置图标;有 描边 / 实底 两种变体 | +| 文本按钮 + 图标 | 蓝色文本链按钮:`更多 »` `展开 ⌄` `下一页 »|` `收起 ⌃` `|⌜ 上一页` | +| 主按钮 + 默认按钮 | `确定`(主) `取消`(默认) `返回`(默认) 并排,间距 8–12px | + +### 其他状态 + +- **悬浮状态(hover)**:主按钮悬浮时底色变亮(`--color-primary-hover` `#0079FE`),用于 Banner / 登录 / 视觉冲击强的场景。 +- **加载中(loading)**:按钮内前置旋转图标 `⟳ 加载中`,期间 `disabled` 并降低交互;用于异步操作等待反馈。 + +```tsx + +``` + +--- + +## 5.9 输入 - 单选框(选择按钮 / Segmented Radio) + +一组互斥选项,外观为按钮组。两种排布 × 三种选中样式。 + +### 排布方式 + +| 排布 | 说明 | +| --- | --- | +| 拼接式 | 选项首尾相连,仅最外侧两角圆角,相邻共用 1px 边框 | +| 分离式 | 选项之间留间距(约 8px),每个独立 4px 圆角,各自带完整边框 | + +### 选中(已选)样式 × 三种 + +| 样式 | 已选 | 未选 | +| --- | --- | --- | +| 描边式 | 白底 + 蓝边框 `--color-primary` + 蓝字 | 白底 + `--color-border` 边框 + `--color-text` | +| 浅底式 | `--color-primary-bg`(`#EAF4FF`) 底 + 蓝字 + 蓝边框 | 同上 | +| 实底式 | `--color-primary` 实底 + 白字 | 同上 | + +- **带统计**:选项文字后追加计数,如 `已选(230)` / `未选(230)`,计数用次要色 `--color-text-secondary`。 + +实现(拼接式 + 描边选中): + +```tsx +
+ {options.map((o) => ( + + ))} +
+``` + +> 浅底式把选中态换成 `bg-[#EAF4FF] text-[#0062D9]`;实底式换成 `bg-[#0062D9] text-white`。 +> 分离式去掉 `divide-x` / `overflow-hidden`,外层用 `gap-2`,每个按钮单独 `rounded border`。 + +### 按钮式页签(Tab as Button) + +图标 + 文字的页签型按钮: +- **激活** 两种:描边(白底 + 蓝边框 + 蓝字)/ 实底(`--color-primary` 底 + 白字) +- **正常**:白底 + `--color-border` 边框 + `--color-text`,前置图标 +- 结构与拼接式选择按钮一致,区别在每项前置一个功能图标。 + +--- + +## 5.11 输入 - 选择器(Select / Dropdown) + +单项输入选择,下拉单选。 + +### 状态 + +| 状态 | 触发框 | +| --- | --- | +| 占位 | `请选择` 占位文字用 `--color-text-secondary`,灰边框 `--color-border`,右侧 `⌄` | +| 展开/聚焦 | 边框变 `--color-primary`,箭头翻转为 `⌃` | +| 已选 | 显示选中项文字(正文色),保持聚焦时蓝边框 | + +### 下拉面板 + +- 白底、圆角 4px、投影(如 `shadow-lg`),最大高度后出现滚动条 +- 选项默认 `--color-text`;**hover / 选中** 项:`--color-primary-bg`(`#EAF4FF`) 底 + `--color-primary` 文字 +- **分组**:分组标题用小号灰字 `--color-text-secondary`,组之间用 1px 分割线 `--color-border` + +实现示意: + +```tsx +// 触发框 + + +// 下拉项 +
  • {label}
  • + +// 分组标题 +
    分组标题1
    +``` + +> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。 + +--- + +## 5.14 输入 - 开关(Switch) + +两种形态: + +### 1. 滑块开关(多用于表单、表格中) + +- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」 +- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」 +- 切换时滑块带左右过渡动画 + +```tsx + +``` + +### 2. 单选框作为开关(分段式) + +- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框 +- 即 5.9 拼接式「实底选择按钮」的两选项特例 + +```tsx +
    + + +
    +``` diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index fa49e65a..660465b4 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -66,6 +66,7 @@ function AppProviders({ children }: { children: React.ReactNode }) { attribute="class" storageKey="strategy-theme" defaultTheme="light" + themes={["light", "dark", "lightblue"]} enableSystem={false} disableTransitionOnChange forcedTheme={forcedTheme} diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx index 8b9f3a79..a605ae7f 100644 --- a/frontend-web/src/components/workspace/agents/agent-selector.tsx +++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx @@ -29,6 +29,8 @@ interface AgentSelectorProps { selectedAgentId: string | null; onAgentChange: (agentId: string | null) => void; disabled?: boolean; + /** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */ + compact?: boolean; } /** @@ -44,6 +46,7 @@ export function AgentSelector({ selectedAgentId, onAgentChange, disabled, + compact = false, }: AgentSelectorProps) { const [open, setOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false); @@ -76,12 +79,17 @@ export function AgentSelector({ disabled={disabled} className={cn( "gap-1.5! rounded-full border px-3! shadow-none transition-colors", - selectedAgent - ? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]" - : "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700", + compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium", + compact + ? "border-transparent bg-gray-50/60 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" + : selectedAgent + ? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]" + : "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700", )} > - + {triggerLabel} diff --git a/frontend-web/src/components/workspace/compact-welcome.tsx b/frontend-web/src/components/workspace/compact-welcome.tsx new file mode 100644 index 00000000..e6b198a6 --- /dev/null +++ b/frontend-web/src/components/workspace/compact-welcome.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +/** + * 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。 + * 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。 + * 非紧凑模式仍用 ,本组件不参与。 + */ +export function CompactWelcome({ className }: { className?: string }) { + return ( +
    + {/* CM logo:浅底圆环 + 主色渐变圆角方块 */} +
    +
    + + CM + +
    +
    + +

    + 👋 欢迎使用CM助手 +

    +

    + 基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 +

    +
    + ); +} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 95fa306b..3e3b53b4 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -1,1185 +1,1264 @@ -"use client"; - -import type { ChatStatus } from "ai"; -import { - BrainCircuitIcon, - BookMarkedIcon, - CheckIcon, - FileTextIcon, - GraduationCapIcon, - LightbulbIcon, - PaperclipIcon, - PlusIcon, - SparklesIcon, - RocketIcon, - XIcon, - ZapIcon, -} from "lucide-react"; -import { useSearchParams } from "next/navigation"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; - -import { - PromptInput, - PromptInputActionMenu, - PromptInputActionMenuContent, - PromptInputActionMenuItem, - PromptInputActionMenuTrigger, - PromptInputAttachment, - PromptInputAttachments, - PromptInputBody, - PromptInputButton, - PromptInputFooter, - PromptInputSubmit, - PromptInputTextarea, - PromptInputTools, - usePromptInputAttachments, - usePromptInputController, - type PromptInputMessage, -} from "@/components/ai-elements/prompt-input"; -import { Button } from "@/components/ui/button"; -import { ConfettiButton } from "@/components/ui/confetti-button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; -import { apiFetch } from "@/core/api"; -import { getBackendBaseURL } from "@/core/config"; -import { useI18n } from "@/core/i18n/hooks"; -import { useModels } from "@/core/models/hooks"; -import type { AgentThreadContext } from "@/core/threads"; -import { textOfMessage } from "@/core/threads/utils"; -import { desensitize } from "@/lib/desensitize"; -import { cn } from "@/lib/utils"; - -import { AgentSelector } from "@/components/workspace/agents/agent-selector"; - -import { - ModelSelector, - ModelSelectorContent, - ModelSelectorInput, - ModelSelectorItem, - ModelSelectorList, - ModelSelectorName, - ModelSelectorTrigger, -} from "../ai-elements/model-selector"; -import { Suggestion, Suggestions } from "../ai-elements/suggestion"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; - -import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; -import { useUserPreferences } from "@/core/user-preferences"; -import { - isThreadRagModeLocked, - lockThreadRagMode, - useLocalSettings, -} from "@/core/settings"; - - -import { CustomPromptBar } from "./custom-prompt-menu"; -import { useThread } from "./messages/context"; -import { ModeHoverGuide } from "./mode-hover-guide"; -import { Tooltip } from "./tooltip"; - -type InputMode = "flash" | "thinking" | "pro" | "ultra"; - - -function getResolvedMode( - mode: InputMode | undefined, - supportsThinking: boolean, -): InputMode { - if (!supportsThinking && mode !== "flash") { - return "flash"; - } - if (mode) { - return mode; - } - return supportsThinking ? "pro" : "flash"; -} - -export function InputBox({ - className, - disabled, - autoFocus, - status = "ready", - context, - extraHeader, - isNewThread, - threadId, - initialValue, - onContextChange, - onFollowupsVisibilityChange, - onSubmit, - onStop, - promptPrefix, - agentSelector, - assistantId, - hideAdminRecommendedQuestions, - ...props -}: Omit, "onSubmit"> & { - assistantId?: string | null; - status?: ChatStatus; - disabled?: boolean; - context: Omit< - AgentThreadContext, - "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" - > & { - mode: "flash" | "thinking" | "pro" | "ultra" | undefined; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; - writing_mode?: boolean; - memory_injection_enabled?: boolean; - }; - extraHeader?: React.ReactNode; - isNewThread?: boolean; - /** Suppress the global admin "recommended questions" row. The agent chat page - * sets this so only the agent's own common questions surface on its home. */ - hideAdminRecommendedQuestions?: boolean; - threadId: string; - initialValue?: string; - onContextChange?: ( - context: Omit< - AgentThreadContext, - "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" - > & { - mode: "flash" | "thinking" | "pro" | "ultra" | undefined; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; - writing_mode?: boolean; - memory_injection_enabled?: boolean; - }, - ) => void; - onFollowupsVisibilityChange?: (visible: boolean) => void; - onSubmit?: (message: PromptInputMessage) => void; - onStop?: () => void; - /** - * Optional toggle rendered next to the model selector. Used for the - * admin-configurable prompt-prefix switch on the chat input. Omitting the - * prop hides the button entirely. - */ - promptPrefix?: { - label: string; - on: boolean; - onToggle: () => void; - }; - /** - * Optional agent picker shown alongside the model selector. Only the new-chat - * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the - * thread is already bound to one agent. - */ - agentSelector?: { - selectedAgentId: string | null; - onAgentChange: (agentId: string | null) => void; - }; -}) { - const { t } = useI18n(); - const [localSettings, setLocalSettings] = useLocalSettings(); - const searchParams = useSearchParams(); - const [modelDialogOpen, setModelDialogOpen] = useState(false); - const { models } = useModels(); - const { thread, isMock } = useThread(); - const { textInput } = usePromptInputController(); - const promptRootRef = useRef(null); - - const [followups, setFollowups] = useState([]); - const [followupsHidden, setFollowupsHidden] = useState(false); - const [followupsLoading, setFollowupsLoading] = useState(false); - const lastGeneratedForAiIdRef = useRef(null); - const wasStreamingRef = useRef(false); - - const [confirmOpen, setConfirmOpen] = useState(false); - const [pendingSuggestion, setPendingSuggestion] = useState( - null, - ); - - const { questions: recommendedQuestions } = useRecommendedQuestions(); - const userPreferences = useUserPreferences(); - const showAdminRecommendedQuestions = - !hideAdminRecommendedQuestions && - !userPreferences.hide_admin_prompt_prefix && - recommendedQuestions.length > 0; - - useEffect(() => { - if (models.length === 0) { - return; - } - const currentModel = models.find((m) => m.name === context.model_name); - const fallbackModel = currentModel ?? models[0]!; - const supportsThinking = fallbackModel.supports_thinking ?? false; - const nextModelName = fallbackModel.name; - const nextMode = getResolvedMode(context.mode, supportsThinking); - - if (context.model_name === nextModelName && context.mode === nextMode) { - return; - } - - onContextChange?.({ - ...context, - model_name: nextModelName, - mode: nextMode, - }); - }, [context, models, onContextChange]); - - const selectedModel = useMemo(() => { - if (models.length === 0) { - return undefined; - } - return models.find((m) => m.name === context.model_name) ?? models[0]; - }, [context.model_name, models]); - - const resolvedModelName = selectedModel?.name; - - const supportThinking = useMemo( - () => selectedModel?.supports_thinking ?? false, - [selectedModel], - ); - - const supportReasoningEffort = useMemo( - () => selectedModel?.supports_reasoning_effort ?? false, - [selectedModel], - ); - - const handleModelSelect = useCallback( - (model_name: string) => { - const model = models.find((m) => m.name === model_name); - if (!model) { - return; - } - onContextChange?.({ - ...context, - model_name, - mode: getResolvedMode(context.mode, model.supports_thinking ?? false), - reasoning_effort: context.reasoning_effort, - }); - setModelDialogOpen(false); - }, - [onContextChange, context, models], - ); - - const handleModeSelect = useCallback( - (mode: InputMode) => { - onContextChange?.({ - ...context, - mode: getResolvedMode(mode, supportThinking), - reasoning_effort: - mode === "ultra" - ? "high" - : mode === "pro" - ? "medium" - : mode === "thinking" - ? "low" - : "minimal", - }); - }, - [onContextChange, context, supportThinking], - ); - - const handleReasoningEffortSelect = useCallback( - (effort: "minimal" | "low" | "medium" | "high") => { - onContextChange?.({ - ...context, - reasoning_effort: effort, - }); - }, - [onContextChange, context], - ); - - const handleWritingModeToggle = useCallback(() => { - onContextChange?.({ - ...context, - writing_mode: !context.writing_mode, - }); - }, [context, onContextChange]); - - const memoryInjectionEnabled = context.memory_injection_enabled ?? true; - const handleMemoryInjectionToggle = useCallback(() => { - onContextChange?.({ - ...context, - memory_injection_enabled: !memoryInjectionEnabled, - }); - }, [context, memoryInjectionEnabled, onContextChange]); - - const ragModeLocked = isThreadRagModeLocked(threadId); - const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; - const handleRagModeToggle = useCallback(() => { - if (ragModeLocked) { - return; - } - setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); - }, [ragModeEnabled, ragModeLocked, setLocalSettings]); - - const handleSubmit = useCallback( - async (message: PromptInputMessage) => { - if (status === "streaming") { - onStop?.(); - return; - } - if (!message.text) { - return; - } - setFollowups([]); - setFollowupsHidden(false); - setFollowupsLoading(false); - if (ragModeEnabled) { - lockThreadRagMode(threadId); - } - - // Guard against submitting before the initial model auto-selection - // effect has flushed thread settings to storage/state. - if (resolvedModelName && context.model_name !== resolvedModelName) { - onContextChange?.({ - ...context, - model_name: resolvedModelName, - mode: getResolvedMode( - context.mode, - selectedModel?.supports_thinking ?? false, - ), - }); - setTimeout(() => onSubmit?.(message), 0); - return; - } - - onSubmit?.(message); - }, - [ - context, - onContextChange, - onSubmit, - onStop, - resolvedModelName, - ragModeEnabled, - selectedModel?.supports_thinking, - status, - threadId, - ], - ); - - const requestFormSubmit = useCallback(() => { - const form = promptRootRef.current?.querySelector("form"); - form?.requestSubmit(); - }, []); - - const handleFollowupClick = useCallback( - (suggestion: string) => { - if (status === "streaming") { - return; - } - const current = (textInput.value ?? "").trim(); - if (current) { - setPendingSuggestion(suggestion); - setConfirmOpen(true); - return; - } - textInput.setInput(suggestion); - setFollowupsHidden(true); - setTimeout(() => requestFormSubmit(), 0); - }, - [requestFormSubmit, status, textInput], - ); - - const confirmReplaceAndSend = useCallback(() => { - if (!pendingSuggestion) { - setConfirmOpen(false); - return; - } - textInput.setInput(pendingSuggestion); - setFollowupsHidden(true); - setConfirmOpen(false); - setPendingSuggestion(null); - setTimeout(() => requestFormSubmit(), 0); - }, [pendingSuggestion, requestFormSubmit, textInput]); - - const confirmAppendAndSend = useCallback(() => { - if (!pendingSuggestion) { - setConfirmOpen(false); - return; - } - const current = (textInput.value ?? "").trim(); - const next = current - ? `${current}\n${pendingSuggestion}` - : pendingSuggestion; - textInput.setInput(next); - setFollowupsHidden(true); - setConfirmOpen(false); - setPendingSuggestion(null); - setTimeout(() => requestFormSubmit(), 0); - }, [pendingSuggestion, requestFormSubmit, textInput]); - - const showFollowups = - !disabled && - !isNewThread && - !followupsHidden && - (followupsLoading || followups.length > 0); - - const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange); - - useEffect(() => { - followupsVisibilityChangeRef.current = onFollowupsVisibilityChange; - }, [onFollowupsVisibilityChange]); - - useEffect(() => { - followupsVisibilityChangeRef.current?.(showFollowups); - }, [showFollowups]); - - useEffect(() => { - return () => followupsVisibilityChangeRef.current?.(false); - }, []); - - useEffect(() => { - const streaming = status === "streaming"; - const wasStreaming = wasStreamingRef.current; - wasStreamingRef.current = streaming; - if (!wasStreaming || streaming) { - return; - } - - if (disabled || isMock) { - return; - } - - const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai"); - const lastAiId = lastAi?.id ?? null; - if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { - return; - } - lastGeneratedForAiIdRef.current = lastAiId; - - const recent = thread.messages - .filter((m) => m.type === "human" || m.type === "ai") - .map((m) => { - const role = m.type === "human" ? "user" : "assistant"; - const content = textOfMessage(m) ?? ""; - return { role, content }; - }) - .filter((m) => m.content.trim().length > 0) - .slice(-6); - - if (recent.length === 0) { - return; - } - - const controller = new AbortController(); - setFollowupsHidden(false); - setFollowupsLoading(true); - setFollowups([]); - - apiFetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: recent, - n: 3, - model_name: context.model_name ?? undefined, - }), - signal: controller.signal, - }) - .then(async (res) => { - if (!res.ok) { - return { suggestions: [] as string[] }; - } - return (await res.json()) as { suggestions?: string[] }; - }) - .then((data) => { - const suggestions = (data.suggestions ?? []) - .map((s) => (typeof s === "string" ? s.trim() : "")) - .filter((s) => s.length > 0) - .slice(0, 5); - setFollowups(suggestions); - }) - .catch(() => { - setFollowups([]); - }) - .finally(() => { - setFollowupsLoading(false); - }); - - return () => controller.abort(); - }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); - - return ( -
    - {extraHeader && ( -
    - {extraHeader} -
    - )} - - {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( -
    - {recommendedQuestions.map((q) => ( - - ))} -
    - )} - - {showFollowups && ( -
    -
    - {followupsLoading ? ( -
    - {t.inputBox.followupLoading} -
    - ) : ( - - {followups.map((s) => ( - handleFollowupClick(s)} - /> - ))} - - - )} -
    -
    - )} -
    - {isNewThread && ( - { - const current = textInput.value?.trim() ?? ""; - textInput.setInput(current ? `${current}\n${content}` : content); - setTimeout(() => { - document - .querySelector("textarea[name='message']") - ?.focus(); - }, 0); - }} - /> - )} -
    - - - {(attachment) => } - - - - - - - {/* TODO: Add more connectors here - - - - - - */} - - - - - 写作 - - - - - - 记忆 - - - - - {ragModeEnabled && ( - - )} - - 参考文献 - - - - - -
    - {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} -
    -
    - {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && - t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
    -
    -
    - - - - {t.inputBox.mode} - - - handleModeSelect("flash")} - > -
    -
    - - {t.inputBox.flashMode} -
    -
    - {t.inputBox.flashModeDescription} -
    -
    - {context.mode === "flash" ? ( - - ) : ( -
    - )} - - {supportThinking && ( - handleModeSelect("thinking")} - > -
    -
    - - {t.inputBox.reasoningMode} -
    -
    - {t.inputBox.reasoningModeDescription} -
    -
    - {context.mode === "thinking" ? ( - - ) : ( -
    - )} - - )} - handleModeSelect("pro")} - > -
    -
    - - {t.inputBox.proMode} -
    -
    - {t.inputBox.proModeDescription} -
    -
    - {context.mode === "pro" ? ( - - ) : ( -
    - )} - - handleModeSelect("ultra")} - > -
    -
    - -
    - {t.inputBox.ultraMode} -
    -
    -
    - {t.inputBox.ultraModeDescription} -
    -
    - {context.mode === "ultra" ? ( - - ) : ( -
    - )} - - - - - - {supportReasoningEffort && context.mode !== "flash" && ( - - -
    - {t.inputBox.reasoningEffort}: - {context.reasoning_effort === "minimal" && - " " + t.inputBox.reasoningEffortMinimal} - {context.reasoning_effort === "low" && - " " + t.inputBox.reasoningEffortLow} - {context.reasoning_effort === "medium" && - " " + t.inputBox.reasoningEffortMedium} - {context.reasoning_effort === "high" && - " " + t.inputBox.reasoningEffortHigh} -
    -
    - - - - {t.inputBox.reasoningEffort} - - - handleReasoningEffortSelect("minimal")} - > -
    -
    - {t.inputBox.reasoningEffortMinimal} -
    -
    - {t.inputBox.reasoningEffortMinimalDescription} -
    -
    - {context.reasoning_effort === "minimal" ? ( - - ) : ( -
    - )} - - handleReasoningEffortSelect("low")} - > -
    -
    - {t.inputBox.reasoningEffortLow} -
    -
    - {t.inputBox.reasoningEffortLowDescription} -
    -
    - {context.reasoning_effort === "low" ? ( - - ) : ( -
    - )} - - handleReasoningEffortSelect("medium")} - > -
    -
    - {t.inputBox.reasoningEffortMedium} -
    -
    - {t.inputBox.reasoningEffortMediumDescription} -
    -
    - {context.reasoning_effort === "medium" || - !context.reasoning_effort ? ( - - ) : ( -
    - )} - - handleReasoningEffortSelect("high")} - > -
    -
    - {t.inputBox.reasoningEffortHigh} -
    -
    - {t.inputBox.reasoningEffortHighDescription} -
    -
    - {context.reasoning_effort === "high" ? ( - - ) : ( -
    - )} - - - - - - )} - - - {agentSelector && ( - - )} - {promptPrefix && ( - - - - {promptPrefix.label} - - {promptPrefix.on && ( - - )} - - - )} - - - -
    - - {selectedModel?.display_name} - -
    -
    -
    - - - - {models.map((m) => ( - handleModelSelect(m.name)} - > -
    - {m.display_name} - - {m.model} - -
    - {m.name === context.model_name ? ( - - ) : ( -
    - )} - - ))} - - - - - - - {!isNewThread && ( -
    - )} - -
    -
    - - - - - {t.inputBox.followupConfirmTitle} - - {t.inputBox.followupConfirmDescription} - - - - - - - - - -
    - ); -} - -function SuggestionList() { - const { t } = useI18n(); - const { textInput } = usePromptInputController(); - const handleSuggestionClick = useCallback( - (prompt: string | undefined) => { - if (!prompt) return; - textInput.setInput(prompt); - setTimeout(() => { - const textarea = document.querySelector( - "textarea[name='message']", - ); - if (textarea) { - const selStart = prompt.indexOf("["); - const selEnd = prompt.indexOf("]"); - if (selStart !== -1 && selEnd !== -1) { - textarea.setSelectionRange(selStart, selEnd + 1); - textarea.focus(); - } - } - }, 500); - }, - [textInput], - ); - return ( - - handleSuggestionClick(t.inputBox.surpriseMePrompt)} - > - {t.inputBox.surpriseMe} - - {t.inputBox.suggestions.map((suggestion) => ( - handleSuggestionClick(suggestion.prompt)} - /> - ))} - - - - - - - {t.inputBox.suggestionsCreate.map((suggestion, index) => - "type" in suggestion && suggestion.type === "separator" ? ( - - ) : ( - !("type" in suggestion) && ( - handleSuggestionClick(suggestion.prompt)} - > - {suggestion.icon && } - {suggestion.suggestion} - - ) - ), - )} - - - - - ); -} - -function AddAttachmentsButton({ className }: { className?: string }) { - const { t } = useI18n(); - const attachments = usePromptInputAttachments(); - return ( - - attachments.openFileDialog()} - > - - - - ); -} +"use client"; + +import type { ChatStatus } from "ai"; +import { + BrainCircuitIcon, + BookMarkedIcon, + CheckIcon, + FileTextIcon, + GraduationCapIcon, + LightbulbIcon, + PaperclipIcon, + PlusIcon, + SparklesIcon, + RocketIcon, + XIcon, + ZapIcon, +} from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; + +import { + PromptInput, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuItem, + PromptInputActionMenuTrigger, + PromptInputAttachment, + PromptInputAttachments, + PromptInputBody, + PromptInputButton, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, + usePromptInputController, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { Button } from "@/components/ui/button"; +import { ConfettiButton } from "@/components/ui/confetti-button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; +import { useI18n } from "@/core/i18n/hooks"; +import { useModels } from "@/core/models/hooks"; +import type { AgentThreadContext } from "@/core/threads"; +import { textOfMessage } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +import { AgentSelector } from "@/components/workspace/agents/agent-selector"; + +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorName, + ModelSelectorTrigger, +} from "../ai-elements/model-selector"; +import { Suggestion, Suggestions } from "../ai-elements/suggestion"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; + +import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { useUserPreferences } from "@/core/user-preferences"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "@/core/settings"; + + +import { CustomPromptBar } from "./custom-prompt-menu"; +import { useThread } from "./messages/context"; +import { ModeHoverGuide } from "./mode-hover-guide"; +import { Tooltip } from "./tooltip"; + +type InputMode = "flash" | "thinking" | "pro" | "ultra"; + + +function getResolvedMode( + mode: InputMode | undefined, + supportsThinking: boolean, +): InputMode { + if (!supportsThinking && mode !== "flash") { + return "flash"; + } + if (mode) { + return mode; + } + return supportsThinking ? "pro" : "flash"; +} + +export function InputBox({ + className, + disabled, + autoFocus, + status = "ready", + context, + extraHeader, + isNewThread, + threadId, + initialValue, + onContextChange, + onFollowupsVisibilityChange, + onSubmit, + onStop, + promptPrefix, + agentSelector, + assistantId, + hideAdminRecommendedQuestions, + compact, + ...props +}: Omit, "onSubmit"> & { + assistantId?: string | null; + status?: ChatStatus; + /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, + * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ + compact?: boolean; + disabled?: boolean; + context: Omit< + AgentThreadContext, + "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" + > & { + mode: "flash" | "thinking" | "pro" | "ultra" | undefined; + reasoning_effort?: "minimal" | "low" | "medium" | "high"; + writing_mode?: boolean; + memory_injection_enabled?: boolean; + }; + extraHeader?: React.ReactNode; + isNewThread?: boolean; + /** Suppress the global admin "recommended questions" row. The agent chat page + * sets this so only the agent's own common questions surface on its home. */ + hideAdminRecommendedQuestions?: boolean; + threadId: string; + initialValue?: string; + onContextChange?: ( + context: Omit< + AgentThreadContext, + "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" + > & { + mode: "flash" | "thinking" | "pro" | "ultra" | undefined; + reasoning_effort?: "minimal" | "low" | "medium" | "high"; + writing_mode?: boolean; + memory_injection_enabled?: boolean; + }, + ) => void; + onFollowupsVisibilityChange?: (visible: boolean) => void; + onSubmit?: (message: PromptInputMessage) => void; + onStop?: () => void; + /** + * Optional toggle rendered next to the model selector. Used for the + * admin-configurable prompt-prefix switch on the chat input. Omitting the + * prop hides the button entirely. + */ + promptPrefix?: { + label: string; + on: boolean; + onToggle: () => void; + }; + /** + * Optional agent picker shown alongside the model selector. Only the new-chat + * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the + * thread is already bound to one agent. + */ + agentSelector?: { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; + }; +}) { + const { t } = useI18n(); + const [localSettings, setLocalSettings] = useLocalSettings(); + const searchParams = useSearchParams(); + const [modelDialogOpen, setModelDialogOpen] = useState(false); + const { models } = useModels(); + const { thread, isMock } = useThread(); + const { textInput } = usePromptInputController(); + const promptRootRef = useRef(null); + + const [followups, setFollowups] = useState([]); + const [followupsHidden, setFollowupsHidden] = useState(false); + const [followupsLoading, setFollowupsLoading] = useState(false); + const lastGeneratedForAiIdRef = useRef(null); + const wasStreamingRef = useRef(false); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingSuggestion, setPendingSuggestion] = useState( + null, + ); + + const { questions: recommendedQuestions } = useRecommendedQuestions(); + const userPreferences = useUserPreferences(); + const showAdminRecommendedQuestions = + !hideAdminRecommendedQuestions && + !userPreferences.hide_admin_prompt_prefix && + recommendedQuestions.length > 0; + + useEffect(() => { + if (models.length === 0) { + return; + } + const currentModel = models.find((m) => m.name === context.model_name); + const fallbackModel = currentModel ?? models[0]!; + const supportsThinking = fallbackModel.supports_thinking ?? false; + const nextModelName = fallbackModel.name; + const nextMode = getResolvedMode(context.mode, supportsThinking); + + if (context.model_name === nextModelName && context.mode === nextMode) { + return; + } + + onContextChange?.({ + ...context, + model_name: nextModelName, + mode: nextMode, + }); + }, [context, models, onContextChange]); + + const selectedModel = useMemo(() => { + if (models.length === 0) { + return undefined; + } + return models.find((m) => m.name === context.model_name) ?? models[0]; + }, [context.model_name, models]); + + const resolvedModelName = selectedModel?.name; + + const supportThinking = useMemo( + () => selectedModel?.supports_thinking ?? false, + [selectedModel], + ); + + const supportReasoningEffort = useMemo( + () => selectedModel?.supports_reasoning_effort ?? false, + [selectedModel], + ); + + const handleModelSelect = useCallback( + (model_name: string) => { + const model = models.find((m) => m.name === model_name); + if (!model) { + return; + } + onContextChange?.({ + ...context, + model_name, + mode: getResolvedMode(context.mode, model.supports_thinking ?? false), + reasoning_effort: context.reasoning_effort, + }); + setModelDialogOpen(false); + }, + [onContextChange, context, models], + ); + + const handleModeSelect = useCallback( + (mode: InputMode) => { + onContextChange?.({ + ...context, + mode: getResolvedMode(mode, supportThinking), + reasoning_effort: + mode === "ultra" + ? "high" + : mode === "pro" + ? "medium" + : mode === "thinking" + ? "low" + : "minimal", + }); + }, + [onContextChange, context, supportThinking], + ); + + const handleReasoningEffortSelect = useCallback( + (effort: "minimal" | "low" | "medium" | "high") => { + onContextChange?.({ + ...context, + reasoning_effort: effort, + }); + }, + [onContextChange, context], + ); + + const handleWritingModeToggle = useCallback(() => { + onContextChange?.({ + ...context, + writing_mode: !context.writing_mode, + }); + }, [context, onContextChange]); + + const memoryInjectionEnabled = context.memory_injection_enabled ?? true; + const handleMemoryInjectionToggle = useCallback(() => { + onContextChange?.({ + ...context, + memory_injection_enabled: !memoryInjectionEnabled, + }); + }, [context, memoryInjectionEnabled, onContextChange]); + + const ragModeLocked = isThreadRagModeLocked(threadId); + const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; + const handleRagModeToggle = useCallback(() => { + if (ragModeLocked) { + return; + } + setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); + }, [ragModeEnabled, ragModeLocked, setLocalSettings]); + + const handleSubmit = useCallback( + async (message: PromptInputMessage) => { + if (status === "streaming") { + onStop?.(); + return; + } + if (!message.text) { + return; + } + setFollowups([]); + setFollowupsHidden(false); + setFollowupsLoading(false); + if (ragModeEnabled) { + lockThreadRagMode(threadId); + } + + // Guard against submitting before the initial model auto-selection + // effect has flushed thread settings to storage/state. + if (resolvedModelName && context.model_name !== resolvedModelName) { + onContextChange?.({ + ...context, + model_name: resolvedModelName, + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + }); + setTimeout(() => onSubmit?.(message), 0); + return; + } + + onSubmit?.(message); + }, + [ + context, + onContextChange, + onSubmit, + onStop, + resolvedModelName, + ragModeEnabled, + selectedModel?.supports_thinking, + status, + threadId, + ], + ); + + const requestFormSubmit = useCallback(() => { + const form = promptRootRef.current?.querySelector("form"); + form?.requestSubmit(); + }, []); + + const handleFollowupClick = useCallback( + (suggestion: string) => { + if (status === "streaming") { + return; + } + const current = (textInput.value ?? "").trim(); + if (current) { + setPendingSuggestion(suggestion); + setConfirmOpen(true); + return; + } + textInput.setInput(suggestion); + setFollowupsHidden(true); + setTimeout(() => requestFormSubmit(), 0); + }, + [requestFormSubmit, status, textInput], + ); + + const confirmReplaceAndSend = useCallback(() => { + if (!pendingSuggestion) { + setConfirmOpen(false); + return; + } + textInput.setInput(pendingSuggestion); + setFollowupsHidden(true); + setConfirmOpen(false); + setPendingSuggestion(null); + setTimeout(() => requestFormSubmit(), 0); + }, [pendingSuggestion, requestFormSubmit, textInput]); + + const confirmAppendAndSend = useCallback(() => { + if (!pendingSuggestion) { + setConfirmOpen(false); + return; + } + const current = (textInput.value ?? "").trim(); + const next = current + ? `${current}\n${pendingSuggestion}` + : pendingSuggestion; + textInput.setInput(next); + setFollowupsHidden(true); + setConfirmOpen(false); + setPendingSuggestion(null); + setTimeout(() => requestFormSubmit(), 0); + }, [pendingSuggestion, requestFormSubmit, textInput]); + + const showFollowups = + !disabled && + !isNewThread && + !followupsHidden && + (followupsLoading || followups.length > 0); + + const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange); + + useEffect(() => { + followupsVisibilityChangeRef.current = onFollowupsVisibilityChange; + }, [onFollowupsVisibilityChange]); + + useEffect(() => { + followupsVisibilityChangeRef.current?.(showFollowups); + }, [showFollowups]); + + useEffect(() => { + return () => followupsVisibilityChangeRef.current?.(false); + }, []); + + useEffect(() => { + const streaming = status === "streaming"; + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = streaming; + if (!wasStreaming || streaming) { + return; + } + + if (disabled || isMock) { + return; + } + + const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai"); + const lastAiId = lastAi?.id ?? null; + if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { + return; + } + lastGeneratedForAiIdRef.current = lastAiId; + + const recent = thread.messages + .filter((m) => m.type === "human" || m.type === "ai") + .map((m) => { + const role = m.type === "human" ? "user" : "assistant"; + const content = textOfMessage(m) ?? ""; + return { role, content }; + }) + .filter((m) => m.content.trim().length > 0) + .slice(-6); + + if (recent.length === 0) { + return; + } + + const controller = new AbortController(); + setFollowupsHidden(false); + setFollowupsLoading(true); + setFollowups([]); + + apiFetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: recent, + n: 3, + model_name: context.model_name ?? undefined, + }), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + return { suggestions: [] as string[] }; + } + return (await res.json()) as { suggestions?: string[] }; + }) + .then((data) => { + const suggestions = (data.suggestions ?? []) + .map((s) => (typeof s === "string" ? s.trim() : "")) + .filter((s) => s.length > 0) + .slice(0, 5); + setFollowups(suggestions); + }) + .catch(() => { + setFollowups([]); + }) + .finally(() => { + setFollowupsLoading(false); + }); + + return () => controller.abort(); + }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); + + return ( +
    + {extraHeader && ( +
    + {extraHeader} +
    + )} + + {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( +
    + {compact && ( +
    + + 试试这些高频业务场景 +
    + )} +
    + {recommendedQuestions.map((q) => ( + + ))} +
    +
    + )} + + {showFollowups && ( +
    +
    + {followupsLoading ? ( +
    + {t.inputBox.followupLoading} +
    + ) : ( + + {followups.map((s) => ( + handleFollowupClick(s)} + /> + ))} + + + )} +
    +
    + )} +
    + {isNewThread && ( + { + const current = textInput.value?.trim() ?? ""; + textInput.setInput(current ? `${current}\n${content}` : content); + setTimeout(() => { + document + .querySelector("textarea[name='message']") + ?.focus(); + }, 0); + }} + /> + )} +
    + + + {(attachment) => } + + + + + + + {/* TODO: Add more connectors here + + + + + + */} + + + + + 写作 + + + + + + 记忆 + + + + + {ragModeEnabled && ( + + )} + + 参考文献 + + + + + +
    + {context.mode === "flash" && } + {context.mode === "thinking" && ( + + )} + {context.mode === "pro" && ( + + )} + {context.mode === "ultra" && ( + + )} +
    +
    + {(context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && + t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
    +
    +
    + + + + {t.inputBox.mode} + + + handleModeSelect("flash")} + > +
    +
    + + {t.inputBox.flashMode} +
    +
    + {t.inputBox.flashModeDescription} +
    +
    + {context.mode === "flash" ? ( + + ) : ( +
    + )} + + {supportThinking && ( + handleModeSelect("thinking")} + > +
    +
    + + {t.inputBox.reasoningMode} +
    +
    + {t.inputBox.reasoningModeDescription} +
    +
    + {context.mode === "thinking" ? ( + + ) : ( +
    + )} + + )} + handleModeSelect("pro")} + > +
    +
    + + {t.inputBox.proMode} +
    +
    + {t.inputBox.proModeDescription} +
    +
    + {context.mode === "pro" ? ( + + ) : ( +
    + )} + + handleModeSelect("ultra")} + > +
    +
    + +
    + {t.inputBox.ultraMode} +
    +
    +
    + {t.inputBox.ultraModeDescription} +
    +
    + {context.mode === "ultra" ? ( + + ) : ( +
    + )} + + + + + + {supportReasoningEffort && context.mode !== "flash" && ( + + +
    + {t.inputBox.reasoningEffort}: + {context.reasoning_effort === "minimal" && + " " + t.inputBox.reasoningEffortMinimal} + {context.reasoning_effort === "low" && + " " + t.inputBox.reasoningEffortLow} + {context.reasoning_effort === "medium" && + " " + t.inputBox.reasoningEffortMedium} + {context.reasoning_effort === "high" && + " " + t.inputBox.reasoningEffortHigh} +
    +
    + + + + {t.inputBox.reasoningEffort} + + + handleReasoningEffortSelect("minimal")} + > +
    +
    + {t.inputBox.reasoningEffortMinimal} +
    +
    + {t.inputBox.reasoningEffortMinimalDescription} +
    +
    + {context.reasoning_effort === "minimal" ? ( + + ) : ( +
    + )} + + handleReasoningEffortSelect("low")} + > +
    +
    + {t.inputBox.reasoningEffortLow} +
    +
    + {t.inputBox.reasoningEffortLowDescription} +
    +
    + {context.reasoning_effort === "low" ? ( + + ) : ( +
    + )} + + handleReasoningEffortSelect("medium")} + > +
    +
    + {t.inputBox.reasoningEffortMedium} +
    +
    + {t.inputBox.reasoningEffortMediumDescription} +
    +
    + {context.reasoning_effort === "medium" || + !context.reasoning_effort ? ( + + ) : ( +
    + )} + + handleReasoningEffortSelect("high")} + > +
    +
    + {t.inputBox.reasoningEffortHigh} +
    +
    + {t.inputBox.reasoningEffortHighDescription} +
    +
    + {context.reasoning_effort === "high" ? ( + + ) : ( +
    + )} + + + + + + )} + + + {agentSelector && ( + + )} + {promptPrefix && ( + + + + {promptPrefix.label} + + {promptPrefix.on && ( + + )} + + + )} + + + +
    + + {selectedModel?.display_name} + +
    +
    +
    + + + + {models.map((m) => ( + handleModelSelect(m.name)} + > +
    + {m.display_name} + + {m.model} + +
    + {m.name === context.model_name ? ( + + ) : ( +
    + )} + + ))} + + + + + + + {!isNewThread && ( +
    + )} + +
    +
    + + + + + {t.inputBox.followupConfirmTitle} + + {t.inputBox.followupConfirmDescription} + + + + + + + + + +
    + ); +} + +function SuggestionList() { + const { t } = useI18n(); + const { textInput } = usePromptInputController(); + const handleSuggestionClick = useCallback( + (prompt: string | undefined) => { + if (!prompt) return; + textInput.setInput(prompt); + setTimeout(() => { + const textarea = document.querySelector( + "textarea[name='message']", + ); + if (textarea) { + const selStart = prompt.indexOf("["); + const selEnd = prompt.indexOf("]"); + if (selStart !== -1 && selEnd !== -1) { + textarea.setSelectionRange(selStart, selEnd + 1); + textarea.focus(); + } + } + }, 500); + }, + [textInput], + ); + return ( + + handleSuggestionClick(t.inputBox.surpriseMePrompt)} + > + {t.inputBox.surpriseMe} + + {t.inputBox.suggestions.map((suggestion) => ( + handleSuggestionClick(suggestion.prompt)} + /> + ))} + + + + + + + {t.inputBox.suggestionsCreate.map((suggestion, index) => + "type" in suggestion && suggestion.type === "separator" ? ( + + ) : ( + !("type" in suggestion) && ( + handleSuggestionClick(suggestion.prompt)} + > + {suggestion.icon && } + {suggestion.suggestion} + + ) + ), + )} + + + + + ); +} + +function AddAttachmentsButton({ className }: { className?: string }) { + const { t } = useI18n(); + const attachments = usePromptInputAttachments(); + return ( + + attachments.openFileDialog()} + > + + + + ); +} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 46026eb8..457c8806 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -5,6 +5,7 @@ import { Download, FileJson, FileText, + MessageSquare, MoreHorizontal, Pencil, Pin, @@ -71,18 +72,22 @@ import { import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { isIMEComposing } from "@/lib/ime"; +import { cn } from "@/lib/utils"; -export function RecentChatList() { +export function RecentChatList({ compact = false }: { compact?: boolean }) { const { t } = useI18n(); const router = useRouter(); const pathname = usePathname(); - const [collapsed, setCollapsed] = useState( + const [internalCollapsed, setInternalCollapsed] = useState( () => localStorage.getItem("sidebar:recent-chats-collapsed") === "true", ); + // 紧凑模式下整列由 WorkspaceSidebar 统一收起,列表本身始终展开、不显示标题折叠钮。 + const collapsed = compact ? false : internalCollapsed; const toggleCollapsed = () => { - const next = !collapsed; - setCollapsed(next); + if (compact) return; + const next = !internalCollapsed; + setInternalCollapsed(next); localStorage.setItem("sidebar:recent-chats-collapsed", String(next)); }; const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = @@ -189,17 +194,29 @@ export function RecentChatList() { <> - - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" - ? t.sidebar.recentChats - : t.sidebar.demoChats} + + + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" + ? t.sidebar.recentChats + : t.sidebar.demoChats} + + {compact && ( + + {visibleThreads.length} + + )} - + {!compact && ( + + )} + {compact && ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 3d24c3a2..fb60500f 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react"; +import { DropletIcon, LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react"; import { useMemo, type ComponentType, type SVGProps } from "react"; import { toast } from "sonner"; @@ -58,15 +58,15 @@ export function AppearanceSettingsPage() { const cleanupWords = citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; - const handleSetGlobalDefault = async (value: "light" | "dark") => { + const handleSetGlobalDefault = async ( + value: "light" | "dark" | "lightblue", + ) => { if (value === globalDefault) return; try { await updateAppearance.mutateAsync({ default_theme: value }); - toast.success( - value === "light" - ? "已将系统默认主题设置为浅色" - : "已将系统默认主题设置为深色", - ); + const label = + value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝"; + toast.success(`已将系统默认主题设置为${label}`); } catch (e) { toast.error(e instanceof Error ? e.message : "保存失败"); } @@ -107,12 +107,20 @@ export function AppearanceSettingsPage() { description: t.settings.appearance.darkDescription, icon: MoonIcon, }, + { + id: "lightblue", + label: t.settings.appearance.lightblue, + description: t.settings.appearance.lightblueDescription, + icon: DropletIcon, + }, ], [ t.settings.appearance.dark, t.settings.appearance.darkDescription, t.settings.appearance.light, t.settings.appearance.lightDescription, + t.settings.appearance.lightblue, + t.settings.appearance.lightblueDescription, ], ); @@ -130,7 +138,7 @@ export function AppearanceSettingsPage() { label={option.label} description={option.description} active={currentTheme === option.id} - mode={option.id as "light" | "dark"} + mode={option.id as "light" | "dark" | "lightblue"} onSelect={(value) => setTheme(value)} /> ))} @@ -165,9 +173,23 @@ export function AppearanceSettingsPage() { 深色 +

    当前系统默认: - {globalDefault === "light" ? "浅色" : "深色"} + {globalDefault === "light" + ? "浅色" + : globalDefault === "dark" + ? "深色" + : "浅蓝"}

    @@ -370,8 +392,8 @@ function ThemePreviewCard({ label: string; description: string; active: boolean; - mode: "light" | "dark"; - onSelect: (mode: "light" | "dark") => void; + mode: "light" | "dark" | "lightblue"; + onSelect: (mode: "light" | "dark" | "lightblue") => void; }) { const previewMode = mode; return ( @@ -401,14 +423,20 @@ function ThemePreviewCard({ "relative overflow-hidden rounded-md border text-xs transition-colors", previewMode === "dark" ? "border-neutral-800 bg-neutral-900 text-neutral-200" - : "border-slate-200 bg-white text-slate-900", + : previewMode === "lightblue" + ? "border-blue-200 bg-white text-slate-900" + : "border-slate-200 bg-white text-slate-900", )} >
    diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 28b50d63..08433495 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; // import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; -import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, 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 { SidebarCollapseButton } from "./workspace-header"; @@ -33,10 +33,19 @@ const NEW_CHAT_CLASS = "data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " + "dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25"; +// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标 +const NEW_CHAT_COMPACT_CLASS = + "h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " + + "border border-[color-mix(in_srgb,var(--primary)_18%,transparent)] " + + "hover:bg-[color-mix(in_srgb,var(--primary)_4%,transparent)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_32%,transparent)] " + + "data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none"; + export function WorkspaceNavChatList({ showCollapseTrigger = false, + compact = false, }: { showCollapseTrigger?: boolean; + compact?: boolean; }) { const { t } = useI18n(); const pathname = usePathname(); @@ -60,21 +69,46 @@ export function WorkspaceNavChatList({ -
    - - - - {t.sidebar.newChat} - - - {showCollapseTrigger && ( - - )} -
    + {compact ? ( + // 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。 +
    + + + + {t.sidebar.newChat} + + + {showCollapseTrigger && ( + + )} +
    + ) : ( +
    + + + + {t.sidebar.newChat} + + + {showCollapseTrigger && ( + + )} +
    + )}
    {!showCollapseTrigger && ( <> diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index dc1e097b..a721a9c9 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -27,11 +27,47 @@ export function WorkspaceSidebar({ const { theme } = useTheme(); const useWhiteSidebar = compactChrome && theme === "light"; + // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 + // collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 + // sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从 + // --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。 + if (compactChrome) { + return ( +
    + + + + + + + +
    + ); + } + return ( <> )} - - {isSidebarOpen && } - {isSidebarOpen && } - {isSidebarOpen && } + + {isSidebarOpen && } + {/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐: + 仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */} + {isSidebarOpen && !compactChrome && } + {isSidebarOpen && !compactChrome && } {!compactChrome && ( diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index a085beb8..0c7fc748 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes'; import { useAppearanceSettings } from '@/core/system-settings/hooks'; // 主题类型 -export type ThemeType = 'dark' | 'light'; +export type ThemeType = 'dark' | 'light' | 'lightblue'; // 主题配置 export interface ThemeConfig { @@ -82,6 +82,28 @@ const themes: Record = { overlay: 'black/30', }, }, + lightblue: { + type: 'lightblue', + name: '浅蓝主题', + colors: { + background: 'white', + backgroundSecondary: 'blue-50', + text: 'gray-800', + textSecondary: 'gray-500', + primary: 'blue-600', + primaryHover: 'blue-500', + border: 'blue-200', + accent: 'blue-700', + success: 'emerald-600', + warning: 'amber-500', + error: 'red-600', + card: 'white', + cardHover: 'blue-50', + input: 'white', + inputBorder: 'blue-200', + overlay: 'black/30', + }, + }, }; const STORAGE_KEY = 'strategy-theme'; @@ -97,7 +119,9 @@ function readPersistedTheme(): ThemeType { return 'light'; } const saved = localStorage.getItem(STORAGE_KEY); - return saved === 'dark' ? 'dark' : 'light'; + if (saved === 'dark') return 'dark'; + if (saved === 'lightblue') return 'lightblue'; + return 'light'; } function hasExplicitChoice(): boolean { @@ -151,7 +175,12 @@ export const ThemeProvider: React.FC = ({ children }) => { const current = localStorage.getItem(STORAGE_KEY); const legacy = localStorage.getItem('theme'); - if (current && current !== 'light' && current !== 'dark') { + if ( + current && + current !== 'light' && + current !== 'dark' && + current !== 'lightblue' + ) { // Garbage value — wipe it so the boot-sync below can take over. localStorage.removeItem(STORAGE_KEY); } @@ -185,7 +214,9 @@ export const ThemeProvider: React.FC = ({ children }) => { const theme: ThemeType = mounted ? effectiveTheme === 'dark' ? 'dark' - : 'light' + : effectiveTheme === 'lightblue' + ? 'lightblue' + : 'light' : readPersistedTheme(); useEffect(() => { diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 0724dbaa..3844d46e 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -448,9 +448,11 @@ export const enUS: Translations = { system: "System", light: "Light", dark: "Dark", + lightblue: "Light Blue", systemDescription: "Match the operating system preference automatically.", lightDescription: "Bright palette with higher contrast for daytime.", darkDescription: "Dim palette that reduces glare for focus.", + lightblueDescription: "A crisp light palette built around blue.", languageTitle: "Language", languageDescription: "Switch between languages.", }, diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 4c695f81..6f21b24a 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -383,9 +383,11 @@ export interface Translations { system: string; light: string; dark: string; + lightblue: string; systemDescription: string; lightDescription: string; darkDescription: string; + lightblueDescription: string; languageTitle: string; languageDescription: string; }; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 64b22bca..df437800 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -429,9 +429,11 @@ export const zhCN: Translations = { system: "系统", light: "浅色", dark: "深色", + lightblue: "浅蓝", systemDescription: "自动跟随系统主题。", lightDescription: "更明亮的配色,适合日间使用。", darkDescription: "更暗的配色,减少眩光方便专注。", + lightblueDescription: "以蓝色为主色的清爽浅色配色。", languageTitle: "语言", languageDescription: "在不同语言之间切换。", }, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 048a424d..864801f1 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -135,7 +135,7 @@ export async function updatePublishSquares( return (await response.json()) as PublishSquaresSettings; } -export type ThemeMode = "light" | "dark"; +export type ThemeMode = "light" | "dark" | "lightblue"; export type ViewMode = "card" | "list"; export interface AppearanceSettings { diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index 0d0c313e..3778cc98 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -5,7 +5,6 @@ */ export const DESENSITIZE_MAP: Record = { 态势: "TS", - 你好: "nh", 滨海作战体系: "第一张网", 滨海作战部队: "第一张网", 'Taiwan Air Force Flight Units': "第二张网", diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 336a1392..bec69ea1 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -28,6 +28,12 @@ import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Welcome } from "@/components/workspace/welcome"; +import { CompactWelcome } from "@/components/workspace/compact-welcome"; +import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; +import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; +import { useSidebar } from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { PanelLeftOpen } from "lucide-react"; import { useAgentSelector } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; @@ -85,6 +91,9 @@ export default function ChatPage() { const routeBase = useRouteBase(); const router = useRouter(); const searchParams = useSearchParams(); + const pathname = usePathname(); + const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = + useSidebar(); const { t } = useI18n(); const [showFollowups, setShowFollowups] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false); @@ -402,6 +411,11 @@ export default function ChatPage() { MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM : undefined; const isLandingState = isNewThread && !hasStartedConversation; + // 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画; + // 判定与 PageRoutes 的 compactWorkspaceSidebar 一致,非紧凑(嵌入/standalone)保持原样。 + const compactChrome = + readSidebarEnabled() && !isChatsIframeWorkspacePath(pathname); + const compactLanding = isLandingState && compactChrome; return ( @@ -419,6 +433,20 @@ export default function ChatPage() {
    + {/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} + {compactChrome && !historySidebarOpen && ( + + )} )}
    -
    +
    - {isLandingState && mounted && ( - - )} + {isLandingState && mounted && + (compactLanding ? ( + + ) : ( + + ))} {mounted ? (
    )} + {compactLanding && ( +
    + CM Assistant V1.2 · 深度推理可能会涉及数据同步 · 信息由AI自动生成,请审慎参考 +
    + )} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
    {t.common.notAvailableInDemoMode} diff --git a/frontend-web/src/pages/LightAppIframeView.tsx b/frontend-web/src/pages/LightAppIframeView.tsx index 7f6b63ed..bc0cb0e5 100644 --- a/frontend-web/src/pages/LightAppIframeView.tsx +++ b/frontend-web/src/pages/LightAppIframeView.tsx @@ -19,8 +19,9 @@ export default function LightAppIframeView() { // 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、 // 把新主题色传给被嵌套应用(不依赖 的 dark class,避免时序读旧值)。 - // TODO: 后续接入 blue 主题后,这里区分 blue 与 dark-blue。 - const themeColor: AppThemeColor = isDark ? "dark-blue" : "red"; + // 深色=dark-blue,浅蓝=blue,其余(红白)=red。 + const themeColor: AppThemeColor = + theme === "dark" ? "dark-blue" : theme === "lightblue" ? "blue" : "red"; const src = useMemo( () => (app ? buildLightAppUrl(app, undefined, themeColor) : ""), [app, themeColor], diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index bb7df093..44f64d54 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -109,7 +109,8 @@ export function WorkspaceLayout({ } as CSSProperties } data-page-sidebar-collapsed={pageSidebarCollapsed} - defaultOpen + // 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开 + defaultOpen={!compactWorkspaceSidebar} > {!workspaceSidebarHidden && ( diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 59944981..533f8ba9 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -471,21 +471,21 @@ export const HighFidelityReport: React.FC = ({ } return ( -
    +
    {/* Header */} -
    +
    -

    最终方案展示

    +

    最终方案展示

    {hasConsensus && ( - + ● 已达成共识 )} - + 生成时间:{generatedAt}
    @@ -521,7 +521,7 @@ export const HighFidelityReport: React.FC = ({ type="button" onClick={() => setSummaryMode(true)} title="返回「方案总结报告」(Markdown 总结 + 问答/修改)" - className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer" + className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer dark:bg-secondary dark:border-border dark:text-foreground dark:hover:bg-accent" > 总结报告 @@ -538,7 +538,7 @@ export const HighFidelityReport: React.FC = ({ @@ -767,15 +767,15 @@ export const HighFidelityReport: React.FC = ({ )} >
    -
    +
    {consensusText ? ( -
    +
    {consensusText}
    ) : ( -

    +

    暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。

    )} @@ -786,8 +786,8 @@ export const HighFidelityReport: React.FC = ({ {/* Section 2: 每席位一个区块 —— 交付内容 + 该席位生成的报告文件(卡片,点击开沙箱) */} {seatOverviews.length === 0 ? ( -
    -

    +

    +

    本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,这里会按席位展示交付与产出文件。

    @@ -803,37 +803,37 @@ export const HighFidelityReport: React.FC = ({ else seatSectionRefs.current.delete(seat.key); }} className={cn( - "scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow", + "scroll-mt-4 bg-white border rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.02)] overflow-hidden transition-shadow dark:bg-card dark:shadow-none", focusedSeat === seat.key ? "border-primary/50 ring-2 ring-primary/30" - : "border-slate-200", + : "border-slate-200 dark:border-border", )} > {/* 区块头:头像 + 席位名 + 徽标,点击折叠/展开 */} @@ -968,21 +968,21 @@ export const HighFidelityReport: React.FC = ({ {fileSandboxTarget && (
    {!fileSandboxIsText ? ( // 二进制(图片 / PDF 等):直接 iframe 下载路由(浏览器原生渲染)。 -
    -
    - +
    +
    + {fileSandboxTarget.file.name}
    ) : fileSandboxContent == null ? ( -
    +
    正在读取文件内容…
    diff --git a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx index bd67b3bf..c9eb3fa3 100644 --- a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx @@ -21,11 +21,14 @@ import { } from "react"; import { ArrowLeft, + Boxes, CheckCircle, + Code2, ExternalLink, LayoutPanelTop, Loader2, Pause, + Pencil, RefreshCw, SendHorizontal, Sparkles, @@ -43,7 +46,7 @@ import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/Dashb import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; -import { parseReportJson } from "@/roundtable-planning/lib/report-template"; +import { parseReportJson, stripReportFence } from "@/roundtable-planning/lib/report-template"; interface Step3DashboardModeProps { selectedModel: string; @@ -65,6 +68,7 @@ function formatNow(): string { } type DashView = "chat" | "page"; +type RenderMode = "app" | "html"; export function Step3DashboardMode({ selectedModel, @@ -101,12 +105,13 @@ export function Step3DashboardMode({ // 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。 const [view, setView] = useState(() => (hasResult ? "page" : "chat")); - // 完成即自动切到大屏页面(含问答修订后的再次完成)。 - const prevPhaseRef = useRef(phase); - useEffect(() => { - if (phase === "done" && prevPhaseRef.current !== "done") setView("page"); - prevPhaseRef.current = phase; - }, [phase]); + // 大屏页面的两种渲染: + // - "app" 应用渲染:用应用内 React 组件(DashboardApp)渲染,交互更丰富(Tab/动画/钻取)。 + // - "html" 网页渲染:直接把自包含 HTML 大屏塞进 iframe,与「新窗口打开 / 分享 / 下载」一致。 + const [renderMode, setRenderMode] = useState("app"); + + // 绘制完成**不再**自动切到大屏页面:用户正在交互区看抽取过程/继续问答时,强行跳走很打断。 + // 想看成品由用户手动点顶栏「查看大屏」。没有产物时初始即落交互区(见上 useState 初值)。 // 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。 useEffect(() => { @@ -114,10 +119,22 @@ export function Step3DashboardMode({ return () => setPreviewEnabled(false); }, [view, setPreviewEnabled]); - // 新窗口打开:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏 - // (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。 + // 新窗口打开:跟随当前渲染方式—— + // - 网页渲染:把自包含 HTML 大屏(blob)直接在新标签页打开(纯网页,无应用壳); + // - 应用渲染:把当前大屏数据写进 localStorage,在新标签页打开应用内 React 大屏 + // (独立全屏路由 /roundtable-dashboard,无侧边栏)。无真实数据时 standalone 回落 DEMO。 const handleOpenNewWindow = () => { if (typeof window === "undefined") return; + + if (renderMode === "html" && html) { + const blob = new Blob([html], { type: "text/html;charset=utf-8" }); + const blobUrl = URL.createObjectURL(blob); + window.open(blobUrl, "_blank", "noopener,noreferrer"); + // 延迟回收,给新标签页足够时间加载(过早 revoke 会打不开)。 + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + return; + } + const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`; try { // 先清掉历史键,避免 localStorage 堆积;再写本次数据。 @@ -135,9 +152,9 @@ export function Step3DashboardMode({ window.open(url, "_blank", "noopener,noreferrer"); }; - const handleRedraw = () => { + // 「编辑」:回到交互区接着上次的聊天记录继续问答修改(不丢弃上下文、不重画)。 + const handleEdit = () => { setView("chat"); - redraw(); }; const isStreaming = @@ -160,19 +177,57 @@ export function Step3DashboardMode({
    {view === "page" ? ( <> + {/* 渲染方式切换:应用渲染(React 组件)/ 网页渲染(自包含 HTML iframe) */} +
    + + +