20 KiB
AI Desktop 项目优化优先级表
快速参考
| 优先级 | 类别 | 问题 | 影响 | 难度 | 预计工作量 |
|---|---|---|---|---|---|
| P0-1 | 性能 | Chat.vue 无限重渲染 | 🔴 高 | ⭐⭐ 中 | 2h |
| P0-2 | 性能 | localStorage 同步写入 | 🔴 高 | ⭐ 易 | 1h |
| P0-3 | 质量 | 重复类型定义 + 错误处理 | 🔴 高 | ⭐⭐⭐ 难 | 3h |
| P0-4 | UX | 登录状态提示不及时 | 🟡 中 | ⭐ 易 | 1h |
| P0-5 | 错误 | Playwright 进程崩溃 | 🔴 高 | ⭐⭐⭐ 难 | 3h |
| P1-1 | 性能 | 搜索结果缓存 | 🟡 中 | ⭐⭐ 中 | 2h |
| P1-2 | UX | 工具调用进度反馈 | 🟡 中 | ⭐ 易 | 1.5h |
| P1-3 | 功能 | 文章Markdown支持 | 🟡 中 | ⭐ 易 | 1h |
| P1-4 | 质量 | 魔法字符串提取 | 🟡 中 | ⭐ 易 | 1.5h |
| P1-5 | 错误 | 死循环检测 | 🟡 中 | ⭐⭐ 中 | 2h |
| P2-1 | 功能 | 对话导出/导入 | 🟢 低 | ⭐⭐ 中 | 3h |
| P2-2 | 功能 | 多会话管理 | 🟢 低 | ⭐⭐⭐ 难 | 5h |
| P2-3 | 功能 | 消息搜索 | 🟢 低 | ⭐ 易 | 1h |
| P2-4 | 功能 | 语法高亮 | 🟢 低 | ⭐⭐ 中 | 2h |
| P2-5 | 质量 | 单元测试 | 🟢 低 | ⭐⭐⭐ 难 | 4h |
第一阶段:立即修复(1-2天,共11小时)
1. P0-2:优化 localStorage 写入 [1小时]
位置: src/renderer/src/views/Chat.vue: Line 467-485
立即行动:
// 只保存最近100条消息,压缩工具结果
const messagesToSave = messages.value.slice(-100)
const compressed = messagesToSave.map(msg => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp,
toolCalls: msg.toolCalls?.map(tc => ({
name: tc.name,
status: tc.status,
result: tc.result?.substring?.(0, 100) // 只保存前100字
}))
}))
localStorage.setItem('chat-messages', JSON.stringify(compressed))
期望效果: localStorage写入速度提升3-5倍
2. P0-4:改进登录状态提示 [1小时]
位置: src/renderer/src/views/Chat.vue, Settings.vue
立即行动:
// Chat.vue onMounted中添加
const isLoggedIn = ref(false)
onMounted(async () => {
// 检查登录状态
const result = await window.electron.ipcRenderer.invoke('check-platform-login', {
platform: 'xiaoheihe'
})
isLoggedIn.value = result.isLoggedIn
if (!isLoggedIn.value) {
ElMessage.warning('请登录小黑盒账号以使用搜索功能')
}
})
// 定期检查(每5分钟)
const checkInterval = setInterval(async () => {
const result = await window.electron.ipcRenderer.invoke('check-platform-login', {
platform: 'xiaoheihe'
})
isLoggedIn.value = result.isLoggedIn
}, 300000)
期望效果: 用户在输入搜索前就知道是否需要登录
3. P0-1:防抖scrollToBottom [2小时]
位置: src/renderer/src/views/Chat.vue: Line 267-420
立即行动:
// 防抖滚动
let scrollTimer: ReturnType<typeof setTimeout> | null = null
const debouncedScrollToBottom = () => {
if (scrollTimer) clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
scrollToBottom()
scrollTimer = null
}, 50)
}
// 替换所有 scrollToBottom() 为 debouncedScrollToBottom()
// 修改 onToken 回调
onToken: (token: string) => {
currentContent += token
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.content = currentContent
debouncedScrollToBottom() // 改这里
}
}
期望效果: 渲染次数减少60-70%
4. P0-3:统一类型定义和错误处理 [3小时]
步骤 1: 创建 src/shared/types.ts
export interface Message {
id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string | null
timestamp?: Date
tool_calls?: ToolCall[]
tool_call_id?: string
name?: string
toolCalls?: ToolCallInfo[] // 兼容旧格式
}
export interface ToolCall {
id: string
type: 'function'
function: {
name: string
arguments: string
}
}
export interface ToolCallInfo {
name: string
args?: Record<string, any>
result?: any
status: 'loading' | 'success' | 'error'
}
export interface ModelConfig {
id: string
name: string
provider: 'openai' | 'deepseek'
model: string
apiKey: string
baseUrl: string
}
export interface Settings {
activeModelId: string | null
modelConfigs: ModelConfig[]
}
步骤 2: 创建 src/renderer/src/utils/errors.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public recoverable: boolean = false,
public context?: Record<string, any>
) {
super(message)
this.name = 'AppError'
}
}
export const createError = {
notLoggedIn: (platform: string) =>
new AppError(
`需要登录 ${platform}`,
'NOT_LOGGED_IN',
true,
{ platform }
),
networkError: (msg?: string) =>
new AppError(msg || '网络连接失败', 'NETWORK_ERROR', true),
toolError: (toolName: string) =>
new AppError(
`${toolName} 执行失败`,
'TOOL_ERROR',
true,
{ tool: toolName }
)
}
export const handleError = (error: Error) => {
if (error instanceof AppError) {
console.error(`[${error.code}] ${error.message}`, error.context)
return {
message: error.message,
recoverable: error.recoverable,
code: error.code
}
}
console.error('Unexpected error:', error)
return {
message: '发生了一个错误',
recoverable: false,
code: 'UNKNOWN'
}
}
步骤 3: 更新导入
Chat.vue:import type { Message } from '@/shared/types'aiService.ts:import type { Message, ToolCall } from '@/shared/types'tools.ts:import type { ToolCall } from '@/shared/types'
期望效果: 类型安全提升,类型定义从3个减少到1个
5. P0-5:处理Playwright进程崩溃 [3小时]
位置: src/main/index.ts: Line 396-406, 352-393
立即行动:
// 添加重试机制
async function fetchArticleContentWithRetry(
url: string,
maxRetries = 3
): Promise<any> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
console.log(`[fetchArticle] Attempt ${attempt + 1}/${maxRetries}`)
return await fetchArticleContent(url)
} catch (error) {
if (attempt === maxRetries - 1) throw error
// 指数退避
const delay = 1000 * Math.pow(2, attempt)
console.log(`[fetchArticle] Retry after ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
// 添加disconnected事件处理
async function getPersistentContext(headless = true): Promise<BrowserContext> {
if (!persistentContext) {
persistentContext = await chromium.launchPersistentContext(userDataDir, {
headless,
viewport: { width: 1280, height: 800 },
userAgent: '...'
})
// 监听断开连接事件
persistentContext.once('disconnected', () => {
console.log('[Browser] Context disconnected')
persistentContext = null
})
}
return persistentContext
}
// 使用 fetchArticleContentWithRetry 替换 fetchArticleContent
ipcMain.handle('fetch-article', async (_, url: string) => {
try {
const result = await fetchArticleContentWithRetry(url)
return { success: true, ...result }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : '抓取文章失败'
}
}
})
期望效果: 偶发性浏览器崩溃导致的请求失败减少80%
第二阶段:改进体验(1-2周,共11小时)
6. P1-4:提取魔法字符串 [1.5小时]
创建 src/renderer/src/constants.ts:
export const TIMEOUTS = {
DEFER_MESSAGE_LOAD: 0,
ENSURE_IME_READY: 100,
ENSURE_RENDERER_READY: 200,
DEBOUNCE_SAVE: 300,
DEBOUNCE_SCROLL: 50,
TOOL_EXECUTION_TIMEOUT: 30000
} as const
export const LIMITS = {
MAX_RECENT_MESSAGES: 50,
MAX_MESSAGE_STORAGE: 100,
MAX_TOOL_ITERATIONS: 10,
MAX_SEARCH_RESULTS_DISPLAY: 10,
ARTICLE_PREVIEW_LENGTH: 1000,
ARTICLE_RESULT_PREVIEW: 200,
SEARCH_MIN_INTERVAL: 3000,
SEARCH_MAX_PER_MINUTE: 10
} as const
export const TOOL_NAMES = {
CHECK_LOGIN: 'check_platform_login',
SEARCH: 'search_platform',
FETCH_ARTICLE: 'fetch_article'
} as const
export const PLATFORMS = {
XIAOHEIHE: 'xiaoheihe'
} as const
在代码中使用:
// 之前
const recentMessages = parsed.slice(-50)
setTimeout(() => { ... }, 300)
// 之后
import { LIMITS, TIMEOUTS } from '@/constants'
const recentMessages = parsed.slice(-LIMITS.MAX_RECENT_MESSAGES)
setTimeout(() => { ... }, TIMEOUTS.DEBOUNCE_SAVE)
7. P1-1:搜索结果缓存 [2小时]
在 src/renderer/src/views/ToolsPanel.vue 中:
// 添加缓存
const articleCache = new Map<string, any>()
const searchCache = new Map<string, any>()
const handleSearch = async () => {
if (!searchQuery.value.trim() || searchLoading.value) return
const cacheKey = `search:${searchQuery.value}`
if (searchCache.has(cacheKey)) {
searchResult.value = searchCache.get(cacheKey)
return
}
try {
searchLoading.value = true
searchResult.value = null
const toolCall = { /* ... */ }
const results = await executeToolCalls([toolCall])
const toolResult = results[0]
const parsedResult = JSON.parse(toolResult.content)
searchResult.value = parsedResult
searchCache.set(cacheKey, parsedResult)
} finally {
searchLoading.value = false
}
}
const handleArticleClick = async (url: string) => {
articleUrl.value = url
// 先检查缓存
if (articleCache.has(url)) {
articleResult.value = articleCache.get(url)
return
}
await handleFetchArticle()
}
const handleFetchArticle = async () => {
if (!articleUrl.value.trim() || fetchLoading.value) return
// 检查缓存
if (articleCache.has(articleUrl.value)) {
articleResult.value = articleCache.get(articleUrl.value)
return
}
try {
fetchLoading.value = true
articleResult.value = null
const toolCall = { /* ... */ }
const results = await executeToolCalls([toolCall])
const toolResult = results[0]
const parsedResult = JSON.parse(toolResult.content)
articleResult.value = parsedResult
articleCache.set(articleUrl.value, parsedResult)
} finally {
fetchLoading.value = false
}
}
8. P1-2:工具调用进度提示 [1.5小时]
修改 src/renderer/src/views/Chat.vue:
// 添加进度跟踪
const toolProgress = ref({
current: 0,
total: 0
})
// 在循环前设置总数
while (currentResponse.tool_calls && /* ... */) {
toolProgress.value.total = currentResponse.tool_calls.length
for (let i = 0; i < currentResponse.tool_calls.length; i++) {
toolProgress.value.current = i + 1
try {
const results = await executeToolCalls([currentResponse.tool_calls[i]])
// ...
} catch (error) {
// ...
}
}
}
// 在模板中显示进度
<div v-if="toolProgress.total > 0" class="tool-progress">
工具执行进度: {{ toolProgress.current }} / {{ toolProgress.total }}
</div>
9. P1-3:文章Markdown支持 [1小时]
修改 src/renderer/src/components/ArticleResultCard.vue:
<template>
<div class="article-result-card">
<!-- 保持现有错误处理 -->
<div v-else-if="data.article" class="article-content">
<!-- 保持meta和stats -->
<div class="article-body">
<!-- 改这里:支持Markdown -->
<MarkdownContent
v-if="data.article.content"
:content="data.article.content"
/>
<!-- 或者如果太长,显示预览 -->
<el-collapse v-if="contentLength > 1000">
<el-collapse-item name="full-content">
<template #title>查看完整内容 ({{ contentLength }} 字)</template>
<MarkdownContent :content="data.article.content" />
</el-collapse-item>
</el-collapse>
</div>
<!-- 保持现有评论展示 -->
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownContent from './MarkdownContent.vue'
// 添加计算属性
const contentLength = computed(() => data.article?.content?.length || 0)
</script>
10. P1-5:死循环检测 [2小时]
修改 src/renderer/src/views/Chat.vue:
// 在工具调用循环中添加检测
const toolCallHistory: string[] = []
const MAX_IDENTICAL_PATTERNS = 2
while (currentResponse.tool_calls && /* ... */) {
const toolNames = currentResponse.tool_calls.map(tc => tc.function.name)
const currentPattern = toolNames.join(',')
// 检测模式重复
const lastPattern = toolCallHistory[toolCallHistory.length - 1]
if (lastPattern === currentPattern) {
const lastLastPattern = toolCallHistory[toolCallHistory.length - 2]
if (lastLastPattern === currentPattern) {
console.warn('检测到死循环模式:', currentPattern)
ElMessage.error('检测到可能的无限循环,已停止工具调用')
break
}
}
toolCallHistory.push(currentPattern)
if (toolCallHistory.length > 10) {
toolCallHistory.shift()
}
// ... 继续执行工具调用
}
第三阶段:功能完善(1个月,共15小时)
11. P2-1:对话导出/导入 [3小时]
创建 src/renderer/src/utils/chatExport.ts:
export interface ChatExport {
version: '1.0'
exportDate: string
messages: any[]
}
export const exportChat = (messages: any[]): void => {
const data: ChatExport = {
version: '1.0',
exportDate: new Date().toISOString(),
messages
}
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `chat-export-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
export const importChat = async (file: File): Promise<ChatExport> => {
const text = await file.text()
const data = JSON.parse(text) as ChatExport
return data
}
在 Chat.vue 中使用:
const exportChat = () => {
chatExport.exportChat(messages.value)
ElMessage.success('对话已导出')
}
const importChat = async (file: File) => {
try {
const data = await chatExport.importChat(file)
if (data.version !== '1.0') {
throw new Error('不支持的版本')
}
messages.value = [
...messages.value,
...data.messages.map(m => ({
...m,
timestamp: new Date(m.timestamp)
}))
]
saveMessages(true)
ElMessage.success('对话已导入')
} catch (error) {
ElMessage.error('导入失败:' + error.message)
}
}
12. P2-3:消息搜索 [1小时]
在 Chat.vue 中添加:
const searchQuery = ref('')
const searchResults = computed(() => {
if (!searchQuery.value) return []
const query = searchQuery.value.toLowerCase()
return messages.value.filter(msg =>
msg.content.toLowerCase().includes(query)
)
})
// 在模板中添加搜索框
<el-input
v-model="searchQuery"
placeholder="搜索消息..."
@keydown.enter="focusFirstSearchResult"
/>
<div v-if="searchQuery && searchResults.length > 0" class="search-results">
找到 {{ searchResults.length }} 条结果
</div>
13. P2-4:语法高亮 [2小时]
修改 src/renderer/src/components/MarkdownContent.vue:
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-dark.css'
marked.use(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language }).value
}
})
)
安装包:
npm install highlight.js marked-highlight
14. P2-2:多会话管理 [5小时]
创建 src/renderer/src/stores/chatSessions.ts:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface ChatSession {
id: string
title: string
createdAt: Date
updatedAt: Date
messages: Message[]
starred: boolean
tags: string[]
}
export const useChatSessions = defineStore('chatSessions', () => {
const sessions = ref<ChatSession[]>([])
const currentSessionId = ref<string | null>(null)
const currentSession = computed(() =>
sessions.value.find(s => s.id === currentSessionId.value)
)
const createSession = () => {
const session: ChatSession = {
id: Date.now().toString(),
title: `对话 ${new Date().toLocaleDateString()}`,
createdAt: new Date(),
updatedAt: new Date(),
messages: [],
starred: false,
tags: []
}
sessions.value.push(session)
currentSessionId.value = session.id
return session
}
return {
sessions,
currentSessionId,
currentSession,
createSession
}
})
15. P2-5:单元测试 [4小时]
安装 vitest:
npm install -D vitest @vitest/ui @testing-library/vue
创建 src/renderer/src/services/__tests__/tools.test.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ToolExecutor } from '../tools'
describe('ToolExecutor', () => {
let executor: ToolExecutor
beforeEach(() => {
executor = new ToolExecutor()
})
describe('execute', () => {
it('should handle invalid JSON arguments', async () => {
const toolCall = {
id: 'test-1',
type: 'function' as const,
function: {
name: 'search_platform',
arguments: '{invalid}'
}
}
const result = await executor.execute(toolCall)
expect(result.content).toContain('参数解析失败')
})
it('should return tool result with correct structure', async () => {
const toolCall = {
id: 'test-2',
type: 'function' as const,
function: {
name: 'check_platform_login',
arguments: JSON.stringify({ platform: 'xiaoheihe' })
}
}
const result = await executor.execute(toolCall)
expect(result.tool_call_id).toBe('test-2')
expect(result.role).toBe('tool')
expect(result.name).toBe('check_platform_login')
})
})
})
快速检查清单
立即检查
- 是否有频繁的console.log警告?
- localStorage中有多少条消息?
- 搜索相同内容会显示缓存吗?
- 工具调用失败时有详细错误吗?
- 未登录时搜索会有提示吗?
一周内完成
- 滚动是否流畅了?
- 消息保存是否快了?
- 代码中还有重复的类型吗?
- 工具调用有进度显示吗?
- 文章能显示Markdown吗?
一个月内完成
- 有导出/导入功能吗?
- 能搜索消息吗?
- 代码有单元测试吗?
- 代码块有语法高亮吗?
预期改进效果
| 指标 | 改进前 | 改进后 | 提升 |
|---|---|---|---|
| 渲染频率 (token处理) | 每token 1次 | 每50ms 1次 | ⬇️ 60-70% |
| localStorage写入 | 同步全量 | 异步压缩 | ⬇️ 60% |
| 搜索重复率 | 0% (无缓存) | 80% (缓存命中) | ⬆️ 80% |
| 类型错误 | 中等 | 几乎无 | ⬇️ 90% |
| 首次登录检查 | 无 | 启动时进行 | ✓ 新增 |
总工作量统计
| 阶段 | 项目数 | 总工时 | 优先级 |
|---|---|---|---|
| 第一阶段 | 5个 | 11小时 | P0 |
| 第二阶段 | 5个 | 11小时 | P1 |
| 第三阶段 | 5个 | 15小时 | P2 |
| 合计 | 15个 | 37小时 | - |
建议分配:
- 第一阶段(P0): 本周完成(2-3天)
- 第二阶段(P1): 下周完成(3-4天)
- 第三阶段(P2): 第3-4周完成(持续改进)