From 854a00c2fafed701365cc05475a1d2c0b2f71598 Mon Sep 17 00:00:00 2001
From: admin <572701190@qq.com>
Date: Sun, 19 Apr 2026 03:35:52 +0800
Subject: [PATCH] =?UTF-8?q?fix(editor):=20Checkbox=E7=82=B9=E5=87=BB?=
=?UTF-8?q?=E5=A4=B1=E6=95=88=20+=20AI=E5=85=A8=E5=B1=80=E4=B8=8A=E4=B8=8B?=
=?UTF-8?q?=E6=96=87=E6=B3=A8=E5=85=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将'允许修改正文'复选框从id/htmlFor绑定改为label直接包裹input,增加e.stopPropagation防止事件冒泡被拦截
- handleAIGenerate中新增editorRef.current.innerText作为全局上下文注入prompt
- currentHtml增加过滤零宽字符
- 优化systemPrompt,明确告知大模型全局参考内容+目标区域源码的双信息源结构
---
src/pages/ReportEditor.tsx | 27 ++++---
工程分析/20260419_0333/实现方案.md | 97 ++++++++++++++++++++++++
工程分析/20260419_0333/测试方案.md | 55 ++++++++++++++
工程分析/20260419_0333/需求分析.md | 37 +++++++++
工程分析/实现方案-2026-04-19-03-19-57.md | 67 ++++++++++++++++
工程分析/测试方案-2026-04-19-03-19-57.md | 34 +++++++++
工程分析/经验记录.md | 25 ++++++
工程分析/需求分析-2026-04-19-03-19-57.md | 31 ++++++++
8 files changed, 362 insertions(+), 11 deletions(-)
create mode 100644 工程分析/20260419_0333/实现方案.md
create mode 100644 工程分析/20260419_0333/测试方案.md
create mode 100644 工程分析/20260419_0333/需求分析.md
create mode 100644 工程分析/实现方案-2026-04-19-03-19-57.md
create mode 100644 工程分析/测试方案-2026-04-19-03-19-57.md
create mode 100644 工程分析/需求分析-2026-04-19-03-19-57.md
diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx
index 9f1c06a..e9205e4 100644
--- a/src/pages/ReportEditor.tsx
+++ b/src/pages/ReportEditor.tsx
@@ -875,14 +875,16 @@ export default function ReportEditor() {
return;
}
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
- const currentHtml = targetRegionEl ? targetRegionEl.innerHTML : '';
+ const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(//g, '').trim() : '';
+ const globalContextText = editorRef.current?.innerText || '';
let messageContent: any;
const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean);
const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)];
- let promptText = `【医生指令】: ${text}`;
+ let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
if (aiModifyEnabled && targetRegionEl) {
- promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
+ promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
}
+ promptText += `【医生指令】: ${text}`;
if (allImages.length > 0) {
messageContent = [];
allImages.forEach(url => {
@@ -893,8 +895,8 @@ export default function ReportEditor() {
messageContent = promptText;
}
const systemPrompt = aiModifyEnabled && targetRegionEl
- ? '你是一名专业的外科医生助理。你需要根据用户的指令及可能提供的截图,修改给定的 HTML 源码。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
- : '你是一名专业的外科医生助理。请根据用户的指令和截图进行分析解答。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
+ ? '你是一名专业的外科医生助理。我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
+ : '你是一名专业的外科医生助理。请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:严格返回合法的 JSON 对象。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
const response = await fetch(`${apiEndpoint}/chat/completions`, {
method: 'POST',
headers: {
@@ -2255,17 +2257,20 @@ export default function ReportEditor() {
)}
-
+
+
+
{/* 视觉参考上下文 */}
diff --git a/工程分析/20260419_0333/实现方案.md b/工程分析/20260419_0333/实现方案.md
new file mode 100644
index 0000000..79805f2
--- /dev/null
+++ b/工程分析/20260419_0333/实现方案.md
@@ -0,0 +1,97 @@
+# 实现方案
+
+## 修改文件
+- `src/pages/ReportEditor.tsx`
+
+## 修改位置 1:Checkbox 包裹结构(约 line 2258-2268)
+
+**原代码**:
+```tsx
+
+ setAiModifyEnabled(e.target.checked)}
+ className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
+ />
+
+ 允许修改正文
+
+
+```
+
+**新代码**:
+```tsx
+
+ {
+ e.stopPropagation();
+ setAiModifyEnabled(e.target.checked);
+ }}
+ className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
+ />
+
+ 允许修改正文
+
+
+```
+
+**变更点**:
+1. 外层 `div` 改为 `label`,直接包裹 `input`
+2. 移除 `id`/`htmlFor`,避免绑定冲突
+3. `onChange` 增加 `e.stopPropagation()` 防止事件冒泡被拦截
+4. `label` 文本改为 `span`
+
+## 修改位置 2:handleAIGenerate Prompt 构建(约 line 877-897)
+
+**原代码**:
+```tsx
+const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
+const currentHtml = targetRegionEl ? targetRegionEl.innerHTML : '';
+// ...
+let promptText = `【医生指令】: ${text}`;
+if (aiModifyEnabled && targetRegionEl) {
+ promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
+}
+```
+
+**新代码**:
+```tsx
+const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
+const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(//g, '').trim() : '';
+const globalContextText = editorRef.current?.innerText || '';
+// ...
+let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
+if (aiModifyEnabled && targetRegionEl) {
+ promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
+}
+promptText += `【医生指令】: ${text}`;
+```
+
+**变更点**:
+1. `currentHtml` 增加 `.replace(//g, '').trim()` 过滤零宽字符
+2. 新增 `globalContextText` 读取整个编辑器的纯文本
+3. `promptText` 重构:先放全局上下文,再放目标区域(如果存在),最后放医生指令
+4. 目标区域为空时显示 `(当前区域为空)` 提示
+
+## 修改位置 3:System Prompt 优化(约 line 895-897)
+
+**原代码**:
+```tsx
+const systemPrompt = aiModifyEnabled && targetRegionEl
+ ? '你是一名专业的外科医生助理。你需要根据用户的指令及可能提供的截图,修改给定的 HTML 源码。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
+ : '你是一名专业的外科医生助理。请根据用户的指令和截图进行分析解答。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
+```
+
+**新代码**:
+```tsx
+const systemPrompt = aiModifyEnabled && targetRegionEl
+ ? '你是一名专业的外科医生助理。我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
+ : '你是一名专业的外科医生助理。请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:严格返回合法的 JSON 对象。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
+```
+
+**变更点**:
+1. 修改模式 systemPrompt 明确告知大模型有两个信息源:全局参考内容 + 目标区域源码
+2. 非修改模式 systemPrompt 明确提到「全局手术报告参考内容」
diff --git a/工程分析/20260419_0333/测试方案.md b/工程分析/20260419_0333/测试方案.md
new file mode 100644
index 0000000..24fd03d
--- /dev/null
+++ b/工程分析/20260419_0333/测试方案.md
@@ -0,0 +1,55 @@
+# 测试方案
+
+## 测试环境
+- 浏览器访问 `http://localhost:4173/`
+- 进入「图文报告生成」→ 新建报告
+
+## 测试用例 1:Checkbox 可正常切换
+
+**步骤**:
+1. 打开右侧「AI 撰写」面板
+2. 观察底部「允许修改正文」复选框,当前应为勾选状态(默认 `aiModifyEnabled = true`)
+3. 点击复选框,观察勾选状态是否消失
+4. 再次点击,观察勾选状态是否恢复
+5. 点击复选框左侧的文字「允许修改正文」,观察勾选状态是否切换
+
+**预期结果**:
+- 点击复选框本身:状态正常切换
+- 点击文字标签:状态正常切换
+- 切换时上方「区域锚定」select 的 `disabled` 状态同步变化(禁用/启用)
+
+## 测试用例 2:AI 能看到全局报告内容
+
+**步骤**:
+1. 在编辑器中输入一些文本,例如「气腹压力为 12mmHg」
+2. 插入一个 AI 可编辑区域(如「手术步骤」)
+3. 在 AI 面板中输入:「你能看到当前气腹压力吗?」
+4. 不勾选「允许修改正文」,直接发送
+
+**预期结果**:
+- AI 回复中应提到「12mmHg」或「气腹压力」,表明它读取了全局上下文
+
+## 测试用例 3:AI 能基于全局上下文修改目标区域
+
+**步骤**:
+1. 编辑器中有完整报告内容(含患者信息、手术步骤等)
+2. 在 AI 可编辑区域(如「手术步骤」)中已有部分内容
+3. 勾选「允许修改正文」
+4. 输入指令:「根据全局报告内容,将手术步骤中提到的止血方法更新为电凝止血」
+5. 发送并查看 diff 确认弹窗
+
+**预期结果**:
+- AI 返回的 `updatedHtml` 应能引用全局报告中的其他信息
+- Diff 弹窗能正确展示原文 vs 修改后的内容
+- 确认注入后目标区域内容更新
+
+## 测试用例 4:编译与部署
+
+**步骤**:
+1. 执行 `npm run build`
+2. 确认无 TypeScript 编译错误
+3. 预览服务正常启动并返回 200
+
+**预期结果**:
+- `vite build` 成功完成
+- 预览页面可正常访问
diff --git a/工程分析/20260419_0333/需求分析.md b/工程分析/20260419_0333/需求分析.md
new file mode 100644
index 0000000..1040e62
--- /dev/null
+++ b/工程分析/20260419_0333/需求分析.md
@@ -0,0 +1,37 @@
+# 需求分析
+
+## 时间戳
+2026-04-19 03:33
+
+## 需求来源
+用户反馈 AI 辅助撰写功能存在两个 Bug:
+1. 「允许修改正文」复选框无法被点击切换
+2. AI 大模型无法看到编辑器中的报告内容,无法执行修改正文的指令
+
+## 问题一:Checkbox 无法切换
+
+**现象**:AI 面板底部的「允许修改正文」复选框点击无反应,无法关闭或开启。
+
+**根因分析**:
+- 当前实现使用独立的 `` + `` 组合
+- 在复杂的 React 组件树中,`id`/`htmlFor` 绑定可能因事件冒泡、DOM 结构覆盖或 React 重渲染导致失效
+- 外层 `.ai-region` 等元素可能对点击事件有拦截
+
+**约束条件**:
+- 最小化改动,只改包裹结构,不改样式语义
+- 必须保留 `cursor-pointer` 和原有视觉样式
+
+## 问题二:AI 无法读取编辑器内容
+
+**现象**:用户在 AI 区域外写入了「气腹压力为 12mmHg」等信息,但问 AI「你能看到当前气腹压力吗?」时,AI 回答无法看到。
+
+**根因分析**:
+- `handleAIGenerate` 目前只将「目标 AI 区域的 HTML 源码」发送给大模型
+- 目标区域可能为空(默认只有 ``),导致大模型收到的上下文只有用户指令
+- AI 看不到编辑器中其他区域(如基本信息、其他手术步骤)的内容
+
+**约束条件**:
+- 必须保留现有 `currentHtml` 作为修改目标(用于 diff 注入)
+- 全局上下文使用纯文本而非 HTML,减少 token 消耗和格式干扰
+- 需要过滤 `` 零宽字符
+- systemPrompt 需同步更新,明确告知大模型有两个信息源:全局参考内容 + 目标区域源码
diff --git a/工程分析/实现方案-2026-04-19-03-19-57.md b/工程分析/实现方案-2026-04-19-03-19-57.md
new file mode 100644
index 0000000..ede802d
--- /dev/null
+++ b/工程分析/实现方案-2026-04-19-03-19-57.md
@@ -0,0 +1,67 @@
+# 实现方案 — 2026-04-19-03-19-57
+
+## 1. 方案概述
+在 `ReportEditor.tsx` 中完成两处修补:① 将 `chatInput` 纳入草稿持久化生命周期;② `handleAIGenerate` 中根据是否有图片动态选择 `content` 类型(字符串 vs 数组)。
+
+## 2. 详细步骤
+
+### 步骤 1:chatInput 持久化
+**目标文件**:`src/pages/ReportEditor.tsx`
+**修改内容**:
+1. `stateRef` 增加 `chatInput`:
+ ```ts
+ const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages, chatInput });
+ ```
+2. `saveDraftToStorage` 增加 `chatInput`:
+ ```ts
+ chatMessages: stateRef.current.chatMessages,
+ chatInput: stateRef.current.chatInput
+ ```
+3. 在已有的 `useEffect` 监听 `chatMessages` 下方,增加监听 `chatInput`:
+ ```ts
+ useEffect(() => {
+ stateRef.current.chatInput = chatInput;
+ }, [chatInput]);
+ ```
+4. 所有 4 处草稿恢复分支(初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处)增加:
+ ```ts
+ if (draft.chatInput) setChatInput(draft.chatInput);
+ stateRef.current = { ...stateRef.current, chatInput: draft.chatInput || '' };
+ ```
+
+### 步骤 2:API content 格式自适应
+**目标文件**:`src/pages/ReportEditor.tsx`
+**修改内容**:
+在 `handleAIGenerate` 中,将 `messageContent` 的组装逻辑改为:
+```ts
+const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean);
+const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)];
+let promptText = `【医生指令】: ${text}`;
+if (aiModifyEnabled && targetRegionEl) {
+ promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
+}
+// 动态选择 content 类型
+let finalContent: any = promptText;
+if (allImages.length > 0) {
+ const visionContent: any[] = [];
+ allImages.forEach(url => {
+ visionContent.push({ type: 'image_url', image_url: { url } });
+ });
+ visionContent.push({ type: 'text', text: promptText });
+ finalContent = visionContent;
+}
+```
+然后在 fetch body 中:
+```ts
+messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: finalContent }
+]
+```
+
+## 3. 依赖关系
+两步都在 `ReportEditor.tsx` 中,可视为同一批修改。
+
+## 4. 风险预案
+- 若旧 draft 中无 `chatInput`,`setChatInput('')` 会清空输入框(符合预期)
+- 纯文本模型的 `content` 必须为 `string` 类型,不能是 `number` 或其他类型。`promptText` 是模板字符串,类型安全
diff --git a/工程分析/测试方案-2026-04-19-03-19-57.md b/工程分析/测试方案-2026-04-19-03-19-57.md
new file mode 100644
index 0000000..dcf0533
--- /dev/null
+++ b/工程分析/测试方案-2026-04-19-03-19-57.md
@@ -0,0 +1,34 @@
+# 测试方案 — 2026-04-19-03-19-57
+
+## 1. 测试范围
+- chatInput 持久化(路由切换 + 页面刷新)
+- API content 格式(纯文本 vs 带图片)
+
+## 2. 测试步骤与预期结果
+
+### 场景 1:纯文本消息(修复 400)
+1. 进入 ReportEditor,切换到 AI撰写 Tab
+2. **不上传任何图片,不勾选任何关键帧**
+3. 输入 "你好",按 Enter
+ 预期:Network 面板中请求体 `messages[1].content` 为字符串 `"【医生指令】: 你好"`,API 返回 200
+
+### 场景 2:带图片消息(Vision 格式保留)
+1. 勾选至少 1 个关键帧或上传 1 张本地图片
+2. 输入 "描述这张图片",按 Enter
+ 预期:Network 面板中请求体 `messages[1].content` 为数组,包含 `type: 'image_url'` 和 `type: 'text'` 两项
+
+### 场景 3:chatInput 持久化
+1. 在 AI 输入框中输入一段未发送的文字(不要按 Enter)
+2. 切换到 `/report-manage`,再返回 `/report-editor`
+ 预期:输入框中文字仍然存在
+3. 刷新浏览器
+ 预期:输入框中文字仍然从 draft 恢复
+
+### 场景 4:类型检查与构建
+1. `npm run lint`
+ 预期:0 errors
+2. `npm run build`
+ 预期:成功
+
+## 3. 回滚检查
+- 若测试失败,执行 `git checkout main` 恢复到上一个 commit
diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md
index 580f096..84bfe81 100644
--- a/工程分析/经验记录.md
+++ b/工程分析/经验记录.md
@@ -625,3 +625,28 @@ ReportEditor 采用 `useRef` 作为自动保存的数据快照机制(避免 Re
**D. 后续如何避免问题**
- 新增任何 `useState` 时,除了问自己「是否已加入 stateRef / saveDraftToStorage / state→ref effect」,还必须**逐个审查所有 draft 恢复分支**,确认恢复逻辑完整。
- 调用多模型兼容的 OpenAI 格式 API 时,必须根据「是否有图片附件」动态决定 `content` 的类型(`string` vs `array`),不能无条件发送数组。
+
+
+---
+
+## 记录 29:Checkbox 在复杂 React 组件树中点击失效 + AI 上下文缺失
+
+**A. 具体问题**
+1. AI 面板底部的「允许修改正文」复选框无法点击切换。
+2. AI 无法回答编辑器中已有的报告内容(如「气腹压力是多少」),表现得像「瞎子」。
+
+**B. 产生问题原因**
+1. **Checkbox 失效**:使用了独立的 `` + `` 组合。在复杂的 contentEditable 编辑器 + React 重渲染环境中,`id`/`htmlFor` 的绑定可能因事件冒泡、DOM 结构覆盖或 React 的 reconciliation 导致点击事件无法正确路由到 input。
+2. **AI 上下文缺失**:`handleAIGenerate` 只向大模型发送了「目标 AI 区域的 HTML 源码」。当该区域为空或信息在其他区域时,大模型收到的上下文只有用户指令,自然无法回答。
+
+**C. 解决问题方案**
+1. **Checkbox 修复**:将 `div > input + label` 改为 `label > input + span`,让 label 直接包裹 input,天然扩大点击区域并避免 `id`/`htmlFor` 绑定冲突;`onChange` 中增加 `e.stopPropagation()` 防止事件冒泡被外层拦截。
+2. **AI 上下文增强**:
+ - 新增 `globalContextText = editorRef.current?.innerText || ''`,将编辑器完整纯文本作为全局背景知识注入 prompt
+ - `currentHtml` 增加 `.replace(//g, '').trim()` 过滤零宽字符
+ - 重构 prompt 结构:先放「全局参考内容」,再放「目标区域源码」,最后放「医生指令」
+ - 同步优化 systemPrompt,明确告知大模型有两个信息源
+
+**D. 后续如何避免问题**
+- 在复杂 React 组件(尤其是与 contentEditable 共存)中使用 Checkbox 时,**优先使用 `` 直接包裹 ``** 的写法,避免依赖 `id`/`htmlFor`。
+- 向大模型发送局部修改请求时,**必须同时提供全局上下文**,否则 AI 无法基于文档其他部分的信息进行推理和修改。
diff --git a/工程分析/需求分析-2026-04-19-03-19-57.md b/工程分析/需求分析-2026-04-19-03-19-57.md
new file mode 100644
index 0000000..5e64bb9
--- /dev/null
+++ b/工程分析/需求分析-2026-04-19-03-19-57.md
@@ -0,0 +1,31 @@
+# 需求分析 — 2026-04-19-03-19-57
+
+## 1. 需求背景
+AI 撰写功能仍存在两个体验与错误问题:
+1. **AI 输入框内容丢失**:用户在 textarea 中输入了文字但未发送,切换页面再返回后,输入框内容清空
+2. **API 400 Bad Request**:不带图片发送消息时,Kimi API 返回 400。因为当前代码始终将 `content` 包装为数组(Vision 格式),而纯文本模型要求 `content` 为字符串
+
+## 2. 需求拆解
+- [ ] **Task 1:chatInput 持久化**
+ - `stateRef` 增加 `chatInput`
+ - `saveDraftToStorage` 保存 `chatInput`
+ - 草稿恢复时恢复 `chatInput`
+ - `useEffect` 监听 `chatInput` 同步到 `stateRef`
+- [ ] **Task 2:API content 格式自适应**
+ - 无图片时:`content` 为纯字符串(兼容 Kimi / DeepSeek)
+ - 有图片时:`content` 为 OpenAI Vision 数组格式(兼容 GPT-4o / Qwen-VL)
+
+## 3. 影响范围
+| 文件 | 修改类型 | 风险等级 |
+|------|----------|----------|
+| `src/pages/ReportEditor.tsx` | 修改(持久化 + API 请求体) | 中 |
+
+## 4. 优先级
+- P0:API 400 修复(功能完全不可用)
+- P1:chatInput 持久化(体验优化)
+
+## 5. 验收标准
+- [ ] 不带图片发送消息时,Kimi API 返回 200 而非 400
+- [ ] 带图片发送消息时,仍使用 Vision 数组格式
+- [ ] 在 AI 输入框输入文字后切换页面再返回,文字保留
+- [ ] `npm run lint` 无类型错误