feat(create): 创作首页会话化 - 工作态指示器/计划路线分离/单会话承载分镜/通知中心与空态 hero/历史抽屉分组/行操作 hover 菜单与重命名/任务按钮规范

This commit is contained in:
maomaochong-ai
2026-09-06 20:29:38 +08:00
parent cdf99ba247
commit 87b84b9c9c
8 changed files with 542 additions and 1003 deletions

View File

@@ -295,6 +295,14 @@ func providerPayloadErrorCategory(raw string) (string, bool) {
return "模型服务额度不足,请检查渠道余额或配额", true
case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")):
return "模型不存在或当前渠道未获得模型权限", true
// 推理/思考模式模型通常禁止强制指定工具调用:DeepSeek 思考模式返回
// "Thinking mode does not support this tool_choice",其他 OpenAI 兼容
// 供应商措辞类似。归为固定可行动原因,画布智能体据此把首步的
// tool_choice=required 降级为 auto 重试一次。排在通用参数类目之前,
// 避免这类稳定标识落回笼统的"请检查模型和参数"。
case (strings.Contains(normalized, "thinking") || strings.Contains(normalized, "reasoning")) && strings.Contains(normalized, "tool_choice"),
strings.Contains(normalized, "tool_choice") && (strings.Contains(normalized, "not support") || strings.Contains(normalized, "unsupported")):
return "当前模型为思考/推理模式,不支持强制工具调用(tool_choice=required),请改用自动工具选择或更换非思考模式模型", true
case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"):
return "模型服务拒绝了请求,请检查模型和参数", true
}

View File

@@ -608,6 +608,8 @@ func TestProviderPayloadErrorMessageUsesSafeActionableCategories(t *testing.T) {
{name: "moderation", raw: "request blocked by content policy: prompt=private", want: "安全审核"},
{name: "quota", raw: "insufficient quota for api-key=secret", want: "额度不足"},
{name: "model access", raw: "model not found for tenant secret-id", want: "模型不存在"},
{name: "thinking mode rejects forced tool choice", raw: `{"error":{"message":"Thinking mode does not support this tool_choice","request_id":"secret-trace"}}`, want: "不支持强制工具调用"},
{name: "reasoning mode rejects forced tool choice", raw: `{"error":{"message":"tool_choice=required is not supported in reasoning mode"}}`, want: "不支持强制工具调用"},
{name: "unknown", raw: "trace_id=private internal stack", want: "模型服务返回失败"},
}
for _, tt := range tests {
@@ -682,6 +684,12 @@ func TestProviderUserFacingErrorMessageClassifiesRejectedRequestBodies(t *testin
body: "",
want: "请检查模型和参数",
},
{
name: "thinking mode rejects forced tool choice",
statusCode: http.StatusBadRequest,
body: `{"error":{"message":"Thinking mode does not support this tool_choice","request_id":"secret"}}`,
want: "不支持强制工具调用",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -28,12 +28,13 @@ BoardUI 是面向 AI/agent 界面的 React 组件库(62+ 源码组件、400+
| `hooks/use-count-up.ts`(数字滚动) | 抄写至 `web/src/hooks/use-count-up.ts`,保留 easeOutCubic/取消动画语义;适配项目规范:尊重 `prefers-reduced-motion`、暴露 `from` 参数 | 后台运营健康卡(活跃用户 / 生成任务 / 服务质量)数值滚动 |
| agent-thinking(wave 点阵指示器形态) | 形态吸收至 `canvas-agent-chat-ui.tsx`:3×3 对角波光点阵 + `role="status"` + 秒数计时 + reduced-motion 静态降级(不引 UI 资产与 motion 依赖) | Canvas AI 助手工作状态 `AgentWorkingMessage` |
| composer-loader(发送态点亮语义) | 语义吸收:发送中胶囊加主题色呼吸光环(1.6s 周期,reduced-motion 关闭) | Canvas AI 助手输入胶囊 |
| agent-log(WorkingRow:工作行与计划分离、真实事件驱动、树线连接) | 语义吸收至创建页分镜 pending:胶囊改纯计划路线(去伪当前步高亮),新增独立活动行 `storyboard-workbench-activity`(真实状态文案 + WorkingDots + `role="status"`,复用共享组件) | 创建页 Storyboard 镜头生成中卡片 |
### 2.2 待吸收(按 rollout 计划后续执行)
| BoardUI 资产 | 内容 | 本项目落点 |
|---|---|---|
| agent-log | Agent 执行过程日志/事件流渲染 | Canvas 任务运行面板(TaskLog 升级) |
| agent-log 的 reveal/连接线形态(等第二消费点:画布 TaskLog 升级时抽共享组件,参考 WorkingDots 路径) | 形态参考 | Canvas 任务运行面板(TaskLog 升级,进行中) |
| notification-center | 通知中心(含 agent 完成/失败分组) | 全局通知浮层 / 后台 |
| styles/typography.css | text-* 排版复合类体系 | 对照 `ui-design-system.mdx` 融入 globals.css(rollout §6.2 缺口) |
| utils/cx.ts(tailwind-merge) | 类名合并 | 与项目 `lib/utils.ts` 对齐补 cn() |

View File

@@ -0,0 +1,99 @@
# 首页对话区优化计划(BoardUI 参照自研)
> 状态:已确认执行顺序(2026-09-06)——Phase A 先行、随后 Phase B;代码高亮启用 `@streamdown/code`
> 关联:`docs/plans/boardui-absorption-plan.mdx`(图标/工具栏/空态已完成并落版)、`docs/plans/ui-kit-rollout-plan.mdx`
> 范围文件:`web/src/pages/create/index.tsx`(1897 行)、`web/src/styles/globals.css`(15940 行)、`web/src/components/ai/ai-message-markdown.tsx`
## 1. 背景与目标
首页(`/` = 创作页)对话区仍有多处可优化,用户点名两大方向,缺一不可:
1. **历史会话弹窗**:按钮入口 → 弹层内容 → 列表项 → 行内操作,整体打磨。
2. **消息正文富内容**:AI 回复中渲染的文本、**代码块、表格、图片**等内容形态的呈现质量。
工作方式:以 BoardUI 仓库(`../boardui`)为视觉与交互参照**自研**落地;自研过程中如发现本站代码不合理(硬编码、重复、缺无障碍、结构性膨胀),同步改进——但不引入 BoardUI 的运行时依赖(无 `react-aria-components`、无 `motion`,沿用 AntD + Tailwind + 自研 token)。
### 调研结论速览
**BoardUI 侧**(`components/application/agent-chat/*`):
- 对话外壳为「右侧会话 rail + 主消息区 + 底部输入条」三段式卡片(rail 260px 卡片、主区 `rounded-3xl bg-secondary`、输入条同宽贴底)。
- 会话 rail 交互最值得吸收:行 hover 与选中同底色(hover 即预览选中);行尾时间徽标 hover 时淡出让位给「⋯」菜单(190px 浮层:重命名/标未读/删除,删除项红色);**行内重命名**(自聚焦全选、Enter 提交、Esc 还原、blur 提交);未读蓝点 6px;新建会话置顶按钮行;分组小标。
- 消息行操作簇:hover 显露、`focus-within` 保留、流式中禁用隐藏(28px 圆形、`rounded-md`、tertiary 图标色、hover 底色)。
- 用户消息为右侧气泡:`max-w-[75%] rounded-2xl px-3 py-[11px] bg-background-primary shadow-card`;assistant 通栏无气泡,与消息簇同宽(`max-w-3xl`)排版。
- 输入条为 52px 胶囊 + 36px 圆形控制钮;发送中变「停止」;下方一条 26px 状态行如实显示 provider/消息数。
- 空态:标题 + 次级说明 + 胶囊建议钮;首 token 到达前以「思考中」占位。
- **注意**:BoardUI 的 agent-chat 是纯文本 starter,全库无 markdown/代码块/表格/图片渲染组件(已 grep 证实:无 react-markdown/shiki/prism/`language-` 类)。故**富内容方向以本站自研管线为主干**,BoardUI 仅提供容器语汇(chip/复制钮/滚动容器/间距节奏)。
**本站侧现状**(引用为现状快照,实施前以最新行号为准):
- 消息渲染:assistant 消息头含镜头徽标/模式/模型/时间/状态与「生成同款」,推理过程可折叠(`MessageReasoning`),正文经 `AIMessageMarkdown`(`web/src/components/ai/ai-message-markdown.tsx`,streamdown 流式渲染,`parseIncompleteMarkdown`,逐词淡入)。
- 富内容现状(样式见 `globals.css` 15206–15297):
- 代码块:仅有衬底 pre(`rounded r-sm`、`foreground 7%` 底),**无语言标签条、无复制按钮、无行号**(`lineNumbers={false}`)、无语法高亮。依赖 `@streamdown/code@1.1.1` 已安装但**从未被 import**(沉睡资产,即流式高亮的官方配套)。
- 表格:滚动容器 + 单元格仅下边框 + 表头 5% 底,较朴素;无边框网格/斑马纹/圆角容器。
- 图片:markdown 内 `![...]` 无自定义渲染(无圆角/尺寸约束/点击预览);会话内生成结果以媒体卡片网格呈现并有预览 Modal(含缩放拖拽),此路已通。
- 引用/列表/分割线/行内代码:已有 token 化样式(`color-mix(in srgb, currentColor …)` 自适应明暗)。
- 历史弹窗现状(`index.tsx` `CreationHistoryDrawer` 段):AntD Drawer;含标题计数、搜索框、新建会话卡、时间桶分组(今天/昨天/近 7 天/更早)、行=整行按钮 + 直接删除按钮 + 模式 tag + 摘要截断、空态、`aria-current`。已不粗糙,但与 BoardUI rail 相比缺少:**hover 菜单(重命名/删除并入)、行内重命名、未读标记、时间徽标让位动画、行间 hover 反馈一致性**;「删除」为单点直达按钮存在误删风险(删除虽走确认流程)。
- 输入条:已完成 R2 幽灵按钮统一(32px 透明按钮族),与消息区为同一 820 居中列。
## 2. 待优化清单(分阶段)
### Phase A — 历史会话弹窗升级(借鉴 BoardUI rail 交互)
目标:让「历史」弹窗的列表行达到与 BoardUI rail 同级的信息密度与操作安全度,并补齐行内编辑。
- [ ] A1 行操作重构:删除从「常驻小按钮」改为 hover「⋯」菜单(菜单项:重命名 / 删除[红]);hover 态即选中预览底色;键盘可达(按钮 `aria-expanded`、菜单 Esc 关闭、焦点回退触发器)。
- [ ] A2 行内重命名:行标题双击或菜单「重命名」进入编辑态——输入自聚焦全选,Enter 提交 / Esc 还原 / blur 提交;提交走既有改名逻辑并乐观更新。
- [ ] A3 时间徽标:展示相对时间(刚刚/n 分钟/n 小时/n 天前),hover 行时让位给操作菜单(淡出/淡入,`prefers-reduced-motion` 下直接切换)。
- [ ] A4 行信息补全与排版:摘要截断行 + 消息数/时间粒度统一;选中态、hover 态、焦点环与页面其他按钮族同源(沿用 R2 幽灵钮规范)。
- [ ] A5 空态与加载态核对:无历史时按钮/面板空态文案与插图风格对齐首页空态;打开时骨架或已有加载态统一。
- [ ] A6 删除确认保留既有确认流;确认框按钮规格对齐全局。
验收:悬停/键盘可完成重命名与删除;重命名即时生效;删除需确认;明暗两主题下 hover/选中/焦点可辨;`bunx tsc --noEmit` 通过;提交后浏览器人工验收。
### Phase B — 消息正文富内容打磨(文本/代码块/表格/图片)
目标:AI 回复中的结构化内容达到「可读、可复制、可引用、好看」的水准。以 `AIMessageMarkdown` 的 components 映射为单一扩展点,不另起渲染体系。
- [ ] B1 代码块外壳:语言标签条(语言名取自 `className="language-*"`,未知语言不显示或显 `code`)+ 复制按钮(`aria-label`、成功态反馈)+ 统一圆角/边框/底色 token;横向滚动保留;顶部标签条在流式渲染中稳定不跳动。
- [ ] B2 代码高亮:评估启用 `@streamdown/code`(流式友好,已装未用)。决策点见 §4。若启用需处理:未知语言降级、流式增量着色节流、深色主题 token 化(不引入散落色值)。
- [ ] B3 行号(可选):streamdown 已支持 `lineNumbers`(当前显式关闭)。默认关闭(对话场景非编辑器),提供设置/偏好开关的可行性标注即可,不强推。
- [ ] B4 表格增强:网格边框(弱化)+ 表头底色 + hover 行、粘性表头容器?——对话表格通常短小,做「圆角容器 + 细网格 + 表头 5–8% 底」即可,不引入复杂表格库;列多时滚动容器保留并补滚动提示(可选)。
- [ ] B5 行内图片:markdown `![alt](url)` 渲染为受限尺寸圆角卡片(`max-height` 约束、`object-fit: cover/contain` 由比例定)、点击进入既有预览 Modal(复用生成结果预览组件);加载失败占位(alt 文案 + 破损图标);懒加载。流式期间图片未收尾时的处理与正文一致(不闪跳)。
- [ ] B6 消息级操作簇(如可行且不喧宾夺主):assistant 消息 hover 出现「复制全文/朗读(如已有能力)」等,参照 BoardUI 显示规则(hover 显露、focus-within 保留、流式隐藏);与现有消息头操作区不重复。
- [ ] B7 渲染一致性小修:标题层级字号用 em 但整体应随正文 1em 缩放核验;行内代码与代码块在流式未闭合时的处理(streamdown 已处理,人工验收确认);引用块与代码块间距节奏(`0.8em` 段落间距)核对。
验收:向模型提问触发含代码块/表格/列表/引用内容,肉眼核对明暗主题;代码块复制可用且键盘可达;图片点击预览;流式中无布局跳动;`tsc` 通过;浏览器人工验收。
### Phase C — 代码质量改进(自研中发现的不合理点)
伴随 A/B 落地顺手治理,不单独立项大改:
- [ ] C1 消息正文样式(15206–15297)与代码块新样式统一收口到创作页样式节或提升为共享组件类;新组件(代码块/图片/历史行菜单)优先页面级组件,出现第二处使用时再提升(遵循 AGENTS.md 组件归属规则)。
- [ ] C2 清理内联字面样式(如 `index.tsx` 内 `style={{display:"inline-flex",gap:8}}` 之类散落写法)→ 具名 CSS 类;磁贴/图标尺寸继续走 R3 统一族。
- [ ] C3 时间格式化工具核对:消息时间/会话分组时间/相对时间若存在多套近似实现,合并为一处(验证引用后再删,不臆测)。
- [ ] C4 无障碍核对(随 A/B 每项必查):新增按钮均有 `aria-label`/`aria-expanded`、焦点环、Esc/Enter 语义;`prefers-reduced-motion` 下逐词淡入与让位动画降级为直接切换(若 streamdown 动画类无降级则补 CSS)。
- [ ] C5 `index.tsx` 1897 行:本轮**不拆文件**(页面私有组件合法),但 A/B 新增块保持独立组件定义清晰;若后续页面文件再涨且出现跨页复用再议拆分。
## 3. BoardUI 关键规格速查(实施时对照)
| 能力 | BoardUI 配方 | 本站落点建议 |
| --- | --- | --- |
| rail 行圆角/hover | `rounded-2lg`,hover 与选中同 fill | 历史行 `--r-md`~`--r-lg` 家族,hover 即预览选中 |
| 未读点 | 6px `bg-button-primary` | 会话级未读(如可用 data 支持)否则标注「数据不支持」 |
| 行菜单 | 190px、`p-2`、删除 `text-text-error-primary` | 自制浮层或 AntD Dropdown 定制样式 |
| 时间徽标让位 | 相对时间 hover 淡出让位菜单 | CSS transition + reduced-motion 降级 |
| 用户气泡 | `max-w-[75%] px-3 py-[11px] rounded-2xl` | 已右对齐气泡,核对圆角/内边距/阴影一致性 |
| 消息 hover 操作 | 28px `rounded-md`、流式禁用隐藏 | 消息头已有操作区,评估是否需要消息尾簇 |
| 输入条 | 52px 胶囊 + 36px 圆钮 + 26px 状态行 | 现有 820 幽灵钮族已统一,仅核对与消息区衔接 |
| 空态 | 标题/说明/胶囊建议钮、思考占位 | 已对齐(R2/R3),微调即可 |
## 4. 已确认决策
1. **执行顺序**:Phase A(历史弹窗)先行,随后 Phase B(富内容)——用户已确认(2026-09-06)。
2. **代码高亮方案**:启用 `@streamdown/code`(官方流式配套、已装零新增依赖)——用户已确认(2026-09-06)。实施时:未知语言降级为无高亮;增量着色节流;颜色走主题 token。
3. 行号默认关闭、图片预览复用既有 Modal——按正文推荐默认执行,无异议。
## 5. 落地纪律
- 每个 Phase 独立提交(`feat/fix/refactor` 前缀按仓库规范),提交前 `bunx tsc --noEmit`,改动后同步 `docs/content/docs/progress/pending-test.mdx` 验收段落(含本次删除任务通知已记录的条目)。
- 所有改动保持工作树干净;本计划文档随首轮实施一并提交。
- BoardUI 只读参照;若其视觉与本站 token(明暗自适应)冲突,以本站 token 体系为准。

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* WorkingDots: diagonal wave dot-matrix "busy" indicator.
*
* Form absorbed from boardui agent-thinking (wave variant) — no UI assets or
* extra dependencies; reduced-motion renders a static frame. The grid is drawn
* with `bg-current`, so it inherits whatever text color the caller provides
* (canvas theme accents, CSS vars, etc.).
*/
export function WorkingDots({
cols = 3,
rows = 3,
dotSize = 4,
gap = 2,
tickMs = 90,
minOpacity = 0.14,
trail = 0.32,
className,
}: {
cols?: number;
rows?: number;
dotSize?: number;
gap?: number;
tickMs?: number;
minOpacity?: number;
trail?: number;
className?: string;
}) {
const reducedMotion = useReducedMotion();
const [phase, setPhase] = useState(0);
useEffect(() => {
if (reducedMotion) return;
const timer = window.setInterval(() => setPhase((value) => (value + 1 / 8) % 1), tickMs);
return () => window.clearInterval(timer);
}, [reducedMotion, tickMs]);
const count = cols * rows;
return (
<span aria-hidden className={`grid shrink-0 ${className ?? ""}`} style={{ gridTemplateColumns: `repeat(${cols}, ${dotSize}px)`, gap }}>
{Array.from({ length: count }, (_, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
// Normalized diagonal position, scaled so the wave front turns
// back before reaching the far corner (3/4 for a 3x3 grid,
// matching the original canvas indicator pixel-for-pixel).
const n = Math.max(cols, rows);
const normalized = (col / Math.max(1, cols - 1) + row / Math.max(1, rows - 1)) / 2;
const scalar = normalized * (n / (n + 1));
const behind = (phase - scalar + 1) % 1;
const lit = Math.max(0, 1 - behind / trail) ** 1.5;
const opacity = minOpacity + (1 - minOpacity) * lit;
return <span key={index} className="rounded-[1px] bg-current" style={{ width: dotSize, height: dotSize, opacity }} />;
})}
</span>
);
}
/**
* WorkingGlow: soft breathing halo rendered over a "busy" control (send
* button, composer capsule). Color accepts a hex value or a CSS variable
* (e.g. `var(--creation-text)`); the blurred layer uses color-mix so both
* work. Disabled under prefers-reduced-motion. Caller positions it: wrap the
* control in a `relative` container and pass the matching border radius.
*/
export function WorkingGlow({
active,
color,
radius = 0,
className,
}: {
active: boolean;
color: string;
radius?: number | string;
className?: string;
}) {
const reducedMotion = useReducedMotion();
if (!active || reducedMotion) return null;
const soft = `color-mix(in srgb, ${color} 55%, transparent)`;
return (
<motion.div
aria-hidden
className={className}
style={{ position: "absolute", inset: 0, borderRadius: radius, pointerEvents: "none", boxShadow: `0 0 0 1px ${color}` }}
animate={{
opacity: [0.35, 1, 0.35],
boxShadow: [`0 0 0 1px ${color}`, `0 0 16px 1px ${soft}, 0 0 0 1px ${color}`, `0 0 0 1px ${color}`],
}}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
);
}

View File

@@ -9,6 +9,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
import type { LocalUser } from "@/stores/use-user-store";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill } from "@/services/api/skills";
@@ -50,37 +51,6 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
const WORKING_TEXT = "正在推演...";
// Agent 工作指示器:3×3 点阵对角波光。吸收自 boardui agent-thinking 的
// wave 形态(不引 UI 资产,仅点阵节奏与降级规范);reduced-motion 下保持静态帧。
const WORKING_DOTS = 3;
const WORKING_DOTS_TICK_MS = 90;
const WORKING_DOTS_MIN_OPACITY = 0.14;
const WORKING_DOTS_PHASE_STEP = 1 / 8;
const WORKING_DOTS_TRAIL = 0.32;
function AgentWorkingDots() {
const reducedMotion = useReducedMotion();
const [phase, setPhase] = useState(0);
useEffect(() => {
if (reducedMotion) return;
const timer = window.setInterval(() => setPhase((value) => (value + WORKING_DOTS_PHASE_STEP) % 1), WORKING_DOTS_TICK_MS);
return () => window.clearInterval(timer);
}, [reducedMotion]);
return (
<span aria-hidden className="grid shrink-0" style={{ gridTemplateColumns: `repeat(${WORKING_DOTS}, 4px)`, gap: 2 }}>
{Array.from({ length: WORKING_DOTS * WORKING_DOTS }, (_, index) => {
const col = index % WORKING_DOTS;
const row = Math.floor(index / WORKING_DOTS);
// 对角波相位 0..<1:波前扫过点阵后折返,读作光在格内往返。
const scalar = ((col + row) / (2 * (WORKING_DOTS - 1))) * (WORKING_DOTS / (WORKING_DOTS + 1));
const behind = (phase - scalar + 1) % 1;
const lit = Math.max(0, 1 - behind / WORKING_DOTS_TRAIL) ** 1.5;
const opacity = WORKING_DOTS_MIN_OPACITY + (1 - WORKING_DOTS_MIN_OPACITY) * lit;
return <span key={index} className="rounded-[1px] bg-current" style={{ width: 4, height: 4, opacity }} />;
})}
</span>
);
}
function AgentWorkingElapsed() {
const [elapsed, setElapsed] = useState(0);
@@ -304,7 +274,7 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
return (
<div role="status" aria-live="polite" className="flex items-center gap-2.5" style={{ color: theme.node.muted }}>
<AgentWorkingDots />
<WorkingDots />
<span className="text-sm leading-5">{label}</span>
<AgentWorkingElapsed />
</div>
@@ -417,15 +387,7 @@ export function AgentChatComposer({
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
}}
>
{sending && !reducedMotion ? (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[22px]"
style={{ boxShadow: `0 0 0 1px ${theme.accent.primary}` }}
animate={{ opacity: [0.3, 1, 0.3], boxShadow: [`0 0 0 1px ${theme.accent.primary}66`, `0 0 18px 2px ${theme.accent.primary}55, 0 0 0 1px ${theme.accent.primary}`, `0 0 0 1px ${theme.accent.primary}66`] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
) : null}
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item) => (

View File

@@ -1651,8 +1651,23 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
}
// 上游兼容兜底:DeepSeek 等 OpenAI 兼容渠道的思考/推理模式不允许
// tool_choice=required(后端归类文案见 providerPayloadErrorCategory)。
// required 只用于对话首步把智能体拖入工具循环;遇到该类拒绝时降级为 auto
// 重试一次——auto 在思考模式下可用,对话与工具调用均不受影响。
// 语义与 image.ts 对非 auto tool_choice 的兼容降级策略一致。
const THINKING_MODE_TOOL_CHOICE_REJECTION = "不支持强制工具调用";
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
return runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice, onDelta });
const submit = (choice: "auto" | "required") => runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice: choice, onDelta });
try {
return await submit(toolChoice);
} catch (error) {
if (toolChoice !== "required" || !(error instanceof Error) || !error.message.includes(THINKING_MODE_TOOL_CHOICE_REJECTION)) {
throw error;
}
return submit("auto");
}
}
function summarizeToolCalls(calls: ResponseToolCall[]) {

File diff suppressed because it is too large Load Diff