2026-04-19-03-03-55 修复AI撰写体验:API endpoint斜杠净化、模型列表下拉栏、聊天记录持久化存储

This commit is contained in:
2026-04-19 03:09:46 +08:00
parent d5cbbf9137
commit 9173aa7733
6 changed files with 293 additions and 20 deletions

View File

@@ -73,6 +73,10 @@ export default function ReportEditor() {
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
useEffect(() => {
stateRef.current.chatMessages = chatMessages;
}, [chatMessages]);
useEffect(() => {
if (!editorRef.current) return;
const allFields = editorRef.current.querySelectorAll('.field-value');
@@ -118,7 +122,7 @@ export default function ReportEditor() {
const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false);
const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages });
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
@@ -143,7 +147,8 @@ export default function ReportEditor() {
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
loadedTemplateId: stateRef.current.loadedTemplateId,
chatMessages: stateRef.current.chatMessages
});
}
}, [reportId]);
@@ -187,12 +192,14 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (draft.chatMessages) setChatMessages(draft.chatMessages);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || []
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
@@ -247,12 +254,14 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (draft.chatMessages) setChatMessages(draft.chatMessages);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || []
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
@@ -851,7 +860,7 @@ export default function ReportEditor() {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
const apiKey = provider?.apiKey || '';
const apiEndpoint = provider?.endpoint || 'https://api.moonshot.cn/v1';
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
const modelName = provider?.modelName || 'kimi-k2-5';
if (!apiKey) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key请前往系统设置填写。' }]);
@@ -1150,7 +1159,8 @@ export default function ReportEditor() {
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || []
};
setTimeout(() => updatePageHeight(), 0);
return;

View File

@@ -20,6 +20,7 @@ export default function SystemSettings() {
const [isSaved, setIsSaved] = useState(false);
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
const [modeModalOpen, setModeModalOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -99,18 +100,27 @@ export default function SystemSettings() {
return;
}
try {
const res = await fetch(`${provider.endpoint}/models`, {
const res = await fetch(`${provider.endpoint.replace(/\/+$/, '')}/models`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
alert(`连接成功!可用模型数: ${data.data?.length || '未知'}`);
const models = data.data?.map((m: any) => m.id).filter((id: string) => id) || [];
setAvailableModels(models);
if (models.length > 0 && !provider.modelName) {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: models[0] };
setSettings({ ...settings, aiProviders: next });
}
alert(`连接成功!可用模型数: ${models.length}`);
} else {
alert(`连接失败: ${res.status} ${res.statusText}`);
setAvailableModels([]);
}
} catch (e: any) {
alert(`连接失败: ${e.message}`);
setAvailableModels([]);
}
};
@@ -361,6 +371,21 @@ export default function SystemSettings() {
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Model Name)</label>
{availableModels.length > 0 ? (
<select
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
className="input-minimal bg-white"
>
{availableModels.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
) : (
<input
type="text"
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
@@ -372,7 +397,8 @@ export default function SystemSettings() {
placeholder="kimi-k2-5"
className="input-minimal"
/>
<p className="text-[11px] text-text-muted">使</p>
)}
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,118 @@
# 实现方案 — 2026-04-19-03-03-55
## 1. 方案概述
三处修补:① endpoint 尾部斜杠净化防止 404② testApi 捕获模型列表并动态切换 Model Name 输入框为下拉栏;③ 将 `chatMessages` 纳入现有草稿持久化生命周期。
## 2. 详细步骤
### 步骤 1`src/pages/ReportEditor.tsx` — endpoint 净化
**目标文件**`src/pages/ReportEditor.tsx`
**修改内容**
`handleAIGenerate` 中,将:
```ts
const apiEndpoint = provider?.endpoint || 'https://api.moonshot.cn/v1';
```
改为:
```ts
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
```
### 步骤 2`src/pages/ReportEditor.tsx` — 聊天记录持久化
**目标文件**`src/pages/ReportEditor.tsx`
**修改内容**
1. `stateRef` 增加 `chatMessages`
```ts
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages });
```
2. `saveDraftToStorage` 增加 `chatMessages`
```ts
storage.set(key, {
content: currentContent,
draftReportId: reportId || null,
reportData: stateRef.current.reportData,
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId,
chatMessages: stateRef.current.chatMessages
});
```
3. 在 `setChatMessages` 的调用处同步更新 `stateRef.current.chatMessages`
- `handleAIGenerate` 中发送 user 消息时
- `handleAIGenerate` 中收到 model 消息时
- 也可在 `setChatInput('')` 之后统一用 `useEffect` 监听 `chatMessages` 变化来同步 ref
更简单的方案:增加一个 `useEffect` 监听 `chatMessages`
```ts
useEffect(() => {
stateRef.current.chatMessages = chatMessages;
}, [chatMessages]);
```
4. 初始化 `useEffect`draft 恢复分支)中恢复 `chatMessages`
```ts
if (draft.chatMessages) {
setChatMessages(draft.chatMessages);
}
```
### 步骤 3`src/pages/SystemSettings.tsx` — 模型名称下拉栏
**目标文件**`src/pages/SystemSettings.tsx`
**修改内容**
1. 新增 state
```ts
const [availableModels, setAvailableModels] = useState<string[]>([]);
```
2. 修改 `testApi`
```ts
const testApi = async () => {
const provider = settings.aiProviders[settings.activeAiProvider];
if (!provider?.apiKey) { alert('请先输入 API 密钥'); return; }
try {
const res = await fetch(`${provider.endpoint.replace(/\/+$/, '')}/models`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
const models = data.data?.map((m: any) => m.id).filter((id: string) => id) || [];
setAvailableModels(models);
if (models.length > 0 && !provider.modelName) {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: models[0] };
setSettings({ ...settings, aiProviders: next });
}
alert(`连接成功!可用模型数: ${models.length}`);
} else {
alert(`连接失败: ${res.status} ${res.statusText}`);
setAvailableModels([]);
}
} catch (e: any) {
alert(`连接失败: ${e.message}`);
setAvailableModels([]);
}
};
```
3. Model Name UI 改为条件渲染:
```tsx
{availableModels.length > 0 ? (
<select value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
className="input-minimal bg-white">
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
) : (
<input type="text" ... />
)}
```
## 3. 依赖关系
- 步骤 1 和步骤 2 可并行(都在 ReportEditor.tsx
- 步骤 3 独立SystemSettings.tsx
## 4. 风险预案
- 若 `/models` 接口返回格式非标准 OpenAI 格式(无 `data` 数组),`models` 列表为空,自动回退到 input 输入框
- 若 draft 中没有 `chatMessages`(旧 draft`setChatMessages` 不执行,保持空数组

View File

@@ -0,0 +1,38 @@
# 测试方案 — 2026-04-19-03-03-55
## 1. 测试范围
- API 404 修复endpoint 尾部斜杠)
- 模型名称下拉栏动态切换
- AI 聊天记录路由切换持久化
## 2. 测试步骤与预期结果
### 场景 1Endpoint 尾部斜杠修复
1. 在 SystemSettings 中故意将 Base URL 末尾多加一个 `/``https://api.moonshot.cn/v1/`
2. 保存,进入 ReportEditor发送 AI 消息
预期:请求 URL 应为 `https://api.moonshot.cn/v1/chat/completions`(只有 1 个斜杠),不应 404
### 场景 2模型名称下拉栏
1. 在 SystemSettings 中填写正确的 Base URL 和 API Key
2. 点击"测试连接"
预期alert 显示连接成功,下方模型名称自动变为下拉栏,列出所有可用模型
3. 选择其中一个模型,保存
4. 刷新页面
预期:模型名称仍为下拉栏,选中值保留
### 场景 3聊天记录持久化
1. 进入 ReportEditor切换到 AI撰写 Tab
2. 发送 2-3 条消息user + model
3. 切换到 `/report-manage`,再返回 `/report-editor`
预期AI撰写 Tab 中聊天记录仍然存在,与离开前一致
4. 刷新页面(模拟完全重载)
预期:聊天记录仍然从 draft 中恢复
### 场景 4类型检查与构建
1. `npm run lint`
预期0 errors
2. `npm run build`
预期:成功
## 3. 回滚检查
- 若测试失败,执行 `git checkout main` 恢复到上一个 commit

View File

@@ -544,3 +544,47 @@ SystemSettings 需要支持 4 个服务商Kimi/DeepSeek/OpenAI/自定义)
**D. 后续如何避免问题**
- 当一组配置具有"同构多实例"特征时(多个服务商、多个环境、多个账号),优先使用 `Record<string, Config>` 而非平铺字段。
- 动态表单的 `onChange` 必须注意不可变更新:先浅拷贝外层字典,再浅拷贝当前项,最后修改目标字段。直接 `settings.aiProviders[k].endpoint = x` 会触发 React 引用比较优化导致不刷新。
---
## 记录 26API Endpoint 尾部斜杠导致 404
**A. 具体问题**
SystemSettings 中测试连接成功(`/models` 返回 200但 ReportEditor 中调用 `/chat/completions` 报 404。用户输入的 Base URL 末尾带有 `/`,导致拼接后路径为 `https://api.xxx.com/v1//chat/completions`。
**B. 产生问题原因**
用户从文档复制 Base URL 时,末尾可能带斜杠;代码中直接做字符串拼接 `${apiEndpoint}/chat/completions`,未做净化处理。
**C. 解决问题方案**
在 `handleAIGenerate` 和 `testApi` 中统一对 endpoint 做尾部斜杠移除:
```ts
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
```
**D. 后续如何避免问题**
- 任何从用户输入拼接 URL 的场景,都必须先对基础路径做 `.replace(/\/+$/, '')` 或 `new URL(path, base)` 标准化处理。
- 测试连通性(`/models`)和实际业务调用(`/chat/completions`)应使用同一套 endpoint 净化逻辑,避免"测试通过、调用失败"的认知落差。
---
## 记录 27State 未纳入 Ref 导致自动保存遗漏
**A. 具体问题**
AI 撰写面板的 `chatMessages` 在路由切换后全部丢失。因为 `saveDraftToStorage` 从 `stateRef.current` 读取数据快照,而 `chatMessages` 从未被同步到 `stateRef`。
**B. 产生问题原因**
ReportEditor 采用 `useRef` 作为自动保存的数据快照机制(避免 React state 闭包陷阱)。新增 `chatMessages` state 时,只关注了 UI 渲染,遗漏了与 `stateRef` 的同步。
**C. 解决问题方案**
1. `stateRef` 初始化时包含 `chatMessages`
2. `saveDraftToStorage` 保存对象中增加 `chatMessages: stateRef.current.chatMessages`
3. 增加 `useEffect` 监听 `chatMessages` 变化,实时同步到 `stateRef.current.chatMessages`
4. 所有草稿恢复分支(初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处)均增加 `chatMessages` 的恢复和 ref 同步
**D. 后续如何避免问题**
- 在 `ReportEditor.tsx` 中新增任何 `useState` 时,必须同时问自己三个问题:
1. 这个 state 是否需要持久化到 draft
2. 若需要,是否已加入 `stateRef` 初始化?
3. 若需要,是否已在 `saveDraftToStorage`、所有恢复分支、以及 state→ref 同步 effect 中补齐?
- 建议维护一份 "Draft 持久化字段清单" 注释在 `stateRef` 定义附近,作为新增 state 时的检查单。

View File

@@ -0,0 +1,37 @@
# 需求分析 — 2026-04-19-03-03-55
## 1. 需求背景
AI 撰写功能上线后出现三个体验问题:
1. **SystemSettings 测试连接成功(返回 13 个模型),但 ReportEditor 调用报 404**`POST https://api.moonshot.cn/v1/chat/completions 404`
2. **模型名称只能手动输入**:用户希望测试连接成功后,模型名称自动变为下拉选择(从 `/models` 返回的列表中选择)
3. **AI 聊天记录不持久**:从 ReportEditor 切换到其他页面再返回AI 撰写面板中的聊天记录全部丢失
## 2. 需求拆解
- [ ] **Task 1修复 404 错误**
- 原因分析:测试按钮读取的是 React state界面临时值ReportEditor 读取的是 localStorage。若测试后未点击"保存",两者不一致。另外 `apiEndpoint` 末尾多余斜杠可能导致路径拼接错误(`v1//chat/completions`
- 修复:在 `handleAIGenerate` 中对 `apiEndpoint``.replace(/\/+$/, '')` 净化
- [ ] **Task 2模型名称下拉栏**
- SystemSettings.tsx 增加 `availableModels` 状态
- `testApi` 成功后解析 `/models` 响应,填充 `availableModels`
- Model Name 输入框在有 availableModels 时自动变为 `<select>` 下拉
- [ ] **Task 3AI 聊天记录持久化**
- `stateRef` 增加 `chatMessages` 字段
- `saveDraftToStorage``chatMessages` 存入 draft
- 初始化 `useEffect` 恢复 draft 时,同步恢复 `chatMessages`
## 3. 影响范围
| 文件 | 修改类型 | 风险等级 |
|------|----------|----------|
| `src/pages/ReportEditor.tsx` | 修改endpoint 净化 + 草稿持久化) | 中 |
| `src/pages/SystemSettings.tsx` | 修改testApi + UI 动态切换) | 低 |
## 4. 优先级
- P0404 修复(功能不可用)
- P1聊天记录持久化体验问题
- P1模型名称下拉栏体验优化
## 5. 验收标准
- [ ] ReportEditor 中 AI 调用不再因尾部斜杠导致 404
- [ ] SystemSettings 测试连接成功后,模型名称自动变为下拉栏,可选模型列表
- [ ] 切换页面后返回 ReportEditorAI 聊天记录保留
- [ ] `npm run lint` 无类型错误