新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -1,15 +1,35 @@
//! AI 聊天 Store — 管理对话状态、流式渲染、工具审批、对话管理
//! AI 聊天 Store — 模块级单例 state + useAiStore() 统一入口
//!
//! 架构(路线A:state 单例 + 逻辑外移到 composables):
//! - 本文件只保留模块级单例 reactive state(19 字段)+ useAiStore() 统一入口
//! - 所有方法搬到 src/composables/ai/*,通过 ...useAiXxx(state) 展开到 useAiStore() 返回值
//! - 组件用法不变:`const { state, sendMessage, ... } = useAiStore()` 零改动
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
//!
//! composables 职责:
//! - useAiEvents startListener/stopListener/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
//! - useAiWindow detachPanel/reattachPanel/resumeInDetached/closeDetachedWindow/dockDetached/syncToMain/startFollowMain/stopFollowMain
//! - useAiPanel togglePanel/toggleMaximize/loadProviders/loadSkills/setProvider/clearChat + restoreUiState/persistUiState
//!
//! 注意:
//! - 各 composable 模块加载时会执行其顶层副作用(useAiPanel.restoreUiState + watch appSettings),
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
//! - state 为模块级单例,全应用共享同一引用
import { reactive } from 'vue'
import { listen, emit } from '@tauri-apps/api/event'
import { aiApi } from '../api'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo } from '../api/types'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '../api/types'
import { useAiEvents } from '../composables/ai/useAiEvents'
import { useAiStream } from '../composables/ai/useAiStream'
import { useAiSend } from '../composables/ai/useAiSend'
import { useAiConversations } from '../composables/ai/useAiConversations'
import { useAiWindow } from '../composables/ai/useAiWindow'
import { useAiPanel } from '../composables/ai/useAiPanel'
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
let _msgCounter = 0
const state = reactive({
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '../../stores/ai'` 取用)
export const state = reactive({
messages: [] as AiMessage[],
streaming: false,
currentText: '',
@@ -29,481 +49,35 @@ const state = reactive({
detached: false,
// 吸附跟随中
docked: false,
// 本机技能(`/` 联想)
skills: [] as SkillInfo[],
// 待发送队列生成中发的消息排队AiCompleted 后自动续发)
queue: [] as { text: string; skill?: string }[],
// 归档分组折叠态(默认折叠)
archivedCollapsed: true,
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
lastTokenUsage: null as { prompt: number; completion: number; total: number } | null,
convTokenTotal: null as { prompt: number; completion: number; total: number } | null,
})
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo }
/**
* AI Store 统一入口 — 返回单例 state + 全部方法。
*
* 组件零改动:解构 shape 与拆分前完全一致。每次调用都重新展开 composable 方法
* (composable 内部全部是模块级单例实现,展开的只是引用,无重复实例化开销)。
*/
export function useAiStore() {
async function startListener() {
if (_unlistenAiEvent) return
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', () => {
loadConversations()
})
}
function stopListener() {
_unlistenAiEvent?.()
_unlistenConvChanged?.()
_unlistenAiEvent = null
_unlistenConvChanged = null
}
function notifyConversationChanged() {
emit('ai-conversation-changed', {})
}
/** 后端原始错误转用户友好提示 */
function friendlyError(raw: string): string {
if (/404|not\s*found/i.test(raw)) return '调用失败:接口地址或模型不存在,请检查 Provider 配置'
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
return raw
}
function handleEvent(event: AiChatEvent) {
const convId = event.conversation_id
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
if (convId && !state.activeConversationId) state.activeConversationId = convId
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) {
if (event.type === 'AiCompleted' || event.type === 'AiError') {
state.generatingConvId = null
loadConversations()
}
return
}
// 标记正在生成的对话(完成/错误事件除外)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
state.generatingConvId = convId
}
switch (event.type) {
case 'AiTextDelta':
state.currentText += event.delta
break
case 'AiAgentRound': {
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
if (state.currentText) {
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.content = state.currentText
}
}
state.currentText = ''
state.messages.push({
id: `ai-${++_msgCounter}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
break
}
case 'AiToolCallStarted': {
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'running',
}
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
}
break
}
case 'AiToolCallCompleted': {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'completed'
tc.result = event.result
}
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
break
}
case 'AiApprovalRequired': {
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'pending_approval',
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
if (tc) tc.status = 'pending_approval'
break
}
case 'AiApprovalResult': {
if (!event.approved) {
const tc = findToolCall(event.id)
if (tc) tc.status = 'rejected'
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
}
break
}
case 'AiCompleted': {
if (state.currentText) {
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.content = state.currentText
}
}
state.currentText = ''
state.streaming = false
state.generatingConvId = null
// 清理分离窗口生成态快照
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
loadConversations()
notifyConversationChanged()
break
}
case 'AiError': {
state.streaming = false
state.generatingConvId = null
state.currentText = ''
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
state.messages.push({
id: `err-${++_msgCounter}`,
role: 'assistant',
content: friendlyError(event.error),
isError: true,
timestamp: Date.now(),
})
break
}
}
}
function findToolCall(id: string): AiToolCallInfo | undefined {
for (const msg of state.messages) {
if (msg.toolCalls) {
const tc = msg.toolCalls.find(t => t.id === id)
if (tc) return tc
}
}
return undefined
}
async function sendMessage(text: string) {
if (!text.trim() || state.streaming) return
state.messages.push({
id: `user-${++_msgCounter}`,
role: 'user',
content: text.trim(),
timestamp: Date.now(),
})
state.messages.push({
id: `ai-${++_msgCounter}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
await startListener()
const raw = localStorage.getItem('df-ai-language') || 'auto'
const lang = raw === 'auto'
? (localStorage.getItem('df-language') || 'zh-CN')
: raw
await aiApi.sendMessage(text.trim(), lang)
}
async function approveToolCall(toolCallId: string, approved: boolean) {
await aiApi.approve(toolCallId, approved)
}
async function loadProviders() {
state.providers = await aiApi.listProviders()
}
async function setProvider(providerId: string) {
await aiApi.setProvider(providerId)
state.activeProvider = providerId
}
async function clearChat() {
await aiApi.clearChat()
state.messages = []
state.currentText = ''
state.pendingApprovals = []
state.streaming = false
}
/** 停止当前生成仅发停止信号streaming 状态由后端 AiCompleted 事件收尾 */
async function stopChat() {
await aiApi.stopChat()
}
function togglePanel() {
state.panelOpen = !state.panelOpen
}
function toggleMaximize() {
state.maximized = !state.maximized
}
// ══════════════════════════════════════
// 对话管理
// ══════════════════════════════════════
async function loadConversations() {
try {
state.conversations = await aiApi.listConversations()
} catch {
// 静默失败,不影响主流程
}
}
async function newConversation() {
const result = await aiApi.createConversation()
state.activeConversationId = result.id
state.messages = []
state.currentText = ''
state.pendingApprovals = []
state.streaming = false
await loadConversations()
notifyConversationChanged()
}
async function switchConversation(id: string) {
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
const detail = await aiApi.switchConversation(id)
state.activeConversationId = id
try {
const rawMsgs = typeof detail.messages === 'string'
? JSON.parse(detail.messages)
: detail.messages
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
const toolResultMap = new Map<string, string>()
for (const m of rawMsgs) {
if (m.role === 'tool' && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, m.content || '')
}
}
state.messages = rawMsgs
.filter((m: any) => m.role !== 'tool')
.map((m: any, i: number) => ({
id: `loaded-${i}`,
role: m.role,
content: m.content || '',
timestamp: Date.now(),
toolCalls: m.tool_calls?.map((tc: any) => ({
id: tc.id,
name: tc.function?.name || '',
args: typeof tc.function?.arguments === 'string'
? JSON.parse(tc.function.arguments || '{}')
: tc.function?.arguments || {},
status: 'completed' as const,
result: toolResultMap.get(tc.id),
})),
}))
} catch {
state.messages = []
}
state.currentText = ''
state.pendingApprovals = []
}
async function deleteConversation(id: string) {
await aiApi.deleteConversation(id)
if (state.activeConversationId === id) {
state.activeConversationId = null
state.messages = []
}
await loadConversations()
notifyConversationChanged()
}
async function renameConversation(id: string, title: string) {
await aiApi.renameConversation(id, title)
// 本地同步更新侧栏摘要标题
const conv = state.conversations.find(c => c.id === id)
if (conv) conv.title = title
notifyConversationChanged()
}
function toggleSidebar() {
state.sidebarOpen = !state.sidebarOpen
}
// ══════════════════════════════════════
// 窗口模式
// ══════════════════════════════════════
async function detachPanel() {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existing = await WebviewWindow.getByLabel('ai-detached')
if (existing) {
await existing.setFocus()
return
}
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
if (state.streaming && state.generatingConvId) {
localStorage.setItem('df-ai-gen', state.generatingConvId)
localStorage.setItem('df-ai-text', state.currentText)
}
const convId = state.activeConversationId
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
const win = new WebviewWindow('ai-detached', {
url,
title: 'DevFlow AI',
width: 520,
height: 700,
minWidth: 360,
minHeight: 400,
center: true,
decorations: true,
})
win.once('tauri://created', () => {
state.panelOpen = false
})
win.once('tauri://error', (e) => {
console.error('[AI] 窗口创建失败:', e)
state.detached = false
})
win.once('tauri://destroyed', () => {
state.detached = false
state.docked = false
stopFollowMain()
})
state.detached = true
}
async function reattachPanel() {
stopFollowMain()
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const win = await WebviewWindow.getByLabel('ai-detached')
if (win) {
await win.close()
}
state.detached = false
state.docked = false
state.panelOpen = true
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
}
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
async function resumeInDetached(convId: string | null) {
const id = convId || state.activeConversationId
if (id) await switchConversation(id)
const gen = localStorage.getItem('df-ai-gen')
if (gen) {
// 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加
state.currentText = localStorage.getItem('df-ai-text') || ''
state.messages.push({
id: `ai-${++_msgCounter}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.generatingConvId = gen
}
}
async function closeDetachedWindow() {
state.docked = false
stopFollowMain()
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
await getCurrentWebviewWindow().close()
}
/** 吸附/取消吸附AI 窗口跟随主窗口右侧 */
async function dockDetached() {
// 取消吸附
if (state.docked) {
state.docked = false
stopFollowMain()
return
}
try {
await syncToMain()
state.docked = true
startFollowMain()
} catch (e) {
console.error('[AI] 吸附失败:', e)
}
}
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
async function syncToMain() {
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
const mainWin = await WebviewWindow.getByLabel('main')
const aiWin = getCurrentWebviewWindow()
if (!mainWin) return
const pos = await mainWin.outerPosition()
const size = await mainWin.innerSize()
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
await aiWin.setSize(new PhysicalSize(600, size.height))
}
// ── 主窗口事件跟随 ──
let _unlistenMove: (() => void) | null = null
let _unlistenResize: (() => void) | null = null
async function startFollowMain() {
stopFollowMain()
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const mainWin = await WebviewWindow.getByLabel('main')
if (!mainWin) return
_unlistenMove = await mainWin.onMoved(() => { syncToMain() })
_unlistenResize = await mainWin.onResized(() => { syncToMain() })
}
function stopFollowMain() {
_unlistenMove?.()
_unlistenResize?.()
_unlistenMove = null
_unlistenResize = null
}
return {
state,
sendMessage,
approveToolCall,
loadProviders,
setProvider,
clearChat,
stopChat,
togglePanel,
toggleMaximize,
startListener,
stopListener,
// 对话管理
loadConversations,
newConversation,
switchConversation,
deleteConversation,
renameConversation,
toggleSidebar,
// 窗口模式
detachPanel,
reattachPanel,
closeDetachedWindow,
dockDetached,
resumeInDetached,
...useAiEvents(),
...useAiStream(),
...useAiSend(),
...useAiConversations(),
...useAiWindow(),
...useAiPanel(),
}
}

138
src/stores/appSettings.ts Normal file
View File

@@ -0,0 +1,138 @@
//! 应用设置 KV Store — localStorage → SQLite 迁移的统一持久化层
//!
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
//!
//! 注意:此为「KV 偏好持久化」层,与已有的 `stores/settings.ts`(mock 的 AI 提供商/
//! 连接/通用配置列表)是两件事 —— 那个文件承载静态展示数据,本文件承载运行时偏好,
//! 故单独成文,互不干扰。
import { reactive, ref, watch, type Ref } from 'vue'
import { settingsApi } from '../api/settings'
/// 按 key 合并连续写的 debounce 时长(毫秒)
const SET_DEBOUNCE_MS = 300
/// 已解析值的响应式缓存 —— 用 reactive 对象而非 Map,使 `useSetting` 的 watch 能
/// 在 `loadAll` / `set` / `remove` 写入时被触发,从而同步刷新组件视图
const cache = reactive<Record<string, unknown>>({})
/// 按 key 的 pending 写定时器:连续 set 在窗口内只发最后一次到 SQLite
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
const pendingValues = new Map<string, unknown>()
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
async function loadAll(): Promise<void> {
const all = await settingsApi.getAll()
for (const [k, raw] of Object.entries(all)) {
cache[k] = parse(raw)
}
}
/** 同步取已解析值,未命中或缓存未加载时返回 defaultValue */
function get<T>(key: string, defaultValue: T): T {
const v = cache[key]
return v === undefined ? defaultValue : (v as T)
}
/**
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
*/
async function set(key: string, value: unknown): Promise<void> {
cache[key] = value
pendingValues.set(key, value)
const existing = pendingTimers.get(key)
if (existing) clearTimeout(existing)
const timer = setTimeout(() => {
pendingTimers.delete(key)
const latest = pendingValues.get(key)
pendingValues.delete(key)
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
settingsApi
.set(key, JSON.stringify(latest))
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
}, SET_DEBOUNCE_MS)
pendingTimers.set(key, timer)
}
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */
async function remove(key: string): Promise<void> {
delete cache[key]
pendingValues.delete(key)
const t = pendingTimers.get(key)
if (t) {
clearTimeout(t)
pendingTimers.delete(key)
}
await settingsApi.delete(key)
}
/**
* 组合式:把某 key 绑定到一个 ref,组件可直接 v-model。
* 读走缓存(同步、响应式),写触发 debounced set。
*
* 实现说明:用普通 `ref` 持有当前值,`watch(cache[key])` 单向同步缓存 → ref,
* 这样 `loadAll` 异步填充缓存后能正确刷新组件视图(customRef 的 trigger 不会被
* reactive cache 的异步 mutation 自动唤起,故改用 watch 显式同步)。
*
* @example
* const theme = useSetting('df-theme', 'dark')
* // 模板里 <a-switch v-model="theme" /> —— 改动自动落库
*/
function useSetting<T>(key: string, defaultValue: T): Ref<T> {
const r = ref(get<T>(key, defaultValue)) as Ref<T>
// 缓存 → ref:loadAll/set/remove 改 cache[key] 时同步到 ref
watch(
() => cache[key],
(v) => {
// 写回值与当前 ref 不同时才更新,避免组件 set 后被 watch 回写造成抖动
const next = v === undefined ? defaultValue : (v as T)
if (!Object.is(next, r.value)) r.value = next
},
{ deep: true },
)
// ref → 缓存(及落库):组件改 .value 时触发 debounced set
watch(
r,
(v) => {
// 仅当与缓存当前值不同时才写,避免 watch 回写触发循环
if (!Object.is(v, cache[key])) void set(key, v)
},
{ deep: true },
)
return r
}
/**
* 解析后端原始值:JSON.parse 失败时回退为原字符串(兼容历史非 JSON 数据)。
* null/空串直接返回,避免 `JSON.parse('')` 抛错。
*/
function parse(raw: string): unknown {
if (raw === null || raw === '') return raw
try {
return JSON.parse(raw)
} catch {
return raw
}
}
export function useAppSettingsStore() {
return {
cache,
loadAll,
get,
set,
remove,
useSetting,
}
}

View File

@@ -1,8 +1,9 @@
export { useProjectStore } from './project'
export { useKnowledgeStore } from './knowledge'
export type { KnowledgeItem } from './knowledge'
export { useSettingsStore } from './settings'
export type { AIProvider, Connection, GeneralSettings } from './settings'
export { useAppSettingsStore } from './appSettings'

View File

@@ -1,79 +1,156 @@
import { reactive, computed } from 'vue'
import { knowledgeApi } from '../api'
import type {
KnowledgeRecord,
KnowledgeDetailPayload,
KnowledgeEventRecord,
CreateKnowledgeInput,
UpdateKnowledgeInput,
KnowledgeConfig,
} from '../api/types'
export interface KnowledgeItem {
id: number
title: string
description: string
category: string
tags: string[]
reuseCount: number
score: number
updatedAt: string
}
// ── 全局响应式状态(单例) ──
const state = reactive({
items: [
// 审查规则
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
// Prompt 模板
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
// 踩坑经验
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
// 诊断知识
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
// 部署经验
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
] as KnowledgeItem[],
categories: [
{ key: 'all', label: '全部', icon: '📦' },
{ key: 'review', label: '审查规则', icon: '🔍' },
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
{ key: 'deploy', label: '部署经验', icon: '🚀' },
],
items: [] as KnowledgeRecord[],
candidates: [] as KnowledgeRecord[],
config: null as KnowledgeConfig | null,
loading: false,
error: null as string | null,
})
export function useKnowledgeStore() {
const items = computed(() => state.items)
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
// 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.<key>')
export const KNOWLEDGE_KINDS = [
{ key: 'review_rule', icon: '🔍' },
{ key: 'prompt_template', icon: '💬' },
{ key: 'pitfall', icon: '⚠️' },
{ key: 'architecture_pattern', icon: '🏛️' },
{ key: 'diagnosis', icon: '🩺' },
{ key: 'deployment_note', icon: '🚀' },
{ key: 'workflow_optimization', icon: '⚡' },
] as const
const getByCategory = (category: string) =>
computed(() =>
category === 'all'
? state.items
: state.items.filter(i => i.category === category)
)
const search = (query: string) =>
computed(() => {
if (!query.trim()) return state.items
const q = query.toLowerCase()
return state.items.filter(i =>
i.title.toLowerCase().includes(q) ||
i.tags.some(t => t.toLowerCase().includes(q)) ||
i.description.toLowerCase().includes(q)
)
})
const getCategoryCount = (key: string) =>
key === 'all' ? state.items.length : state.items.filter(i => i.category === key).length
return {
items,
categories: computed(() => state.categories),
getByCategory,
search,
getCategoryCount,
/** 解析 tags JSON 字符串为 string[] */
export function parseTags(tags: string | null): string[] {
if (!tags) return []
try {
const arr = JSON.parse(tags)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
export function useKnowledgeStore() {
// ── 加载列表(默认排除 archived) ──
async function loadList(status?: string) {
state.loading = true
state.error = null
try {
state.items = await knowledgeApi.list(status ?? null)
} catch (e: any) {
state.error = e?.toString() ?? '加载知识库失败'
} finally {
state.loading = false
}
}
// ── 加载审核收件箱(候选列表) ──
async function loadCandidates() {
try {
state.candidates = await knowledgeApi.listCandidates()
} catch (e: any) {
state.error = e?.toString() ?? '加载收件箱失败'
}
}
async function search(query: string, kind?: string) {
state.loading = true
state.error = null
try {
state.items = await knowledgeApi.search({ query, kind })
} catch (e: any) {
state.error = e?.toString() ?? '检索失败'
} finally {
state.loading = false
}
}
async function create(input: CreateKnowledgeInput) {
const record = await knowledgeApi.create(input)
// 手动录入默认 candidate,刷新收件箱
await loadCandidates()
return record
}
async function updateStatus(id: string, status: string) {
await knowledgeApi.updateStatus(id, status)
// 从 items 和 candidates 中同步移除
state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id)
}
async function archive(id: string) {
await knowledgeApi.archive(id)
state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id)
}
// ── 配置 ──
async function loadConfig() {
try {
state.config = await knowledgeApi.getConfig()
} catch (e: any) {
state.error = e?.toString() ?? '加载配置失败'
}
}
async function saveConfig(config: KnowledgeConfig) {
await knowledgeApi.saveConfig(config)
state.config = config
}
async function extractNow() {
await knowledgeApi.extractNow()
await loadCandidates()
}
// ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──
async function getDetail(id: string): Promise<KnowledgeDetailPayload> {
return knowledgeApi.getDetail(id)
}
async function update(id: string, input: UpdateKnowledgeInput): Promise<KnowledgeRecord> {
const updated = await knowledgeApi.update(id, input)
// 同步刷新内存中的列表/收件箱条目
const patch = (arr: KnowledgeRecord[]) => {
const idx = arr.findIndex(k => k.id === id)
if (idx >= 0) arr[idx] = { ...arr[idx], ...updated }
}
patch(state.items)
patch(state.candidates)
return updated
}
async function getEvents(knowledgeId: string, eventType?: string, limit?: number): Promise<KnowledgeEventRecord[]> {
return knowledgeApi.events(knowledgeId, eventType, limit)
}
// 候选数量(供侧栏 badge 用)
const candidateCount = computed(() => state.candidates.length)
return reactive({
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
get items() { return state.items },
get candidates() { return state.candidates },
get config() { return state.config },
get loading() { return state.loading },
get error() { return state.error },
candidateCount,
// actions
loadList, loadCandidates, search, create, updateStatus, archive,
loadConfig, saveConfig, extractNow,
getDetail, update, getEvents,
})
}

View File

@@ -7,6 +7,7 @@ import type { ProjectRecord, TaskRecord, IdeaRecord, WorkflowRecord, WorkflowEve
const state = reactive({
projects: [] as ProjectRecord[],
deletedProjects: [] as ProjectRecord[],
tasks: [] as TaskRecord[],
ideas: [] as IdeaRecord[],
workflowExecutions: [] as WorkflowRecord[],
@@ -24,7 +25,7 @@ const state = reactive({
let _eventUnlisten: (() => void) | null = null
export function useProjectStore() {
function createStore() {
// ── 项目 CRUD ──
async function loadProjects() {
state.loading = true
@@ -38,10 +39,18 @@ export function useProjectStore() {
}
}
async function createProject(name: string, description = '', ideaId?: string) {
const record = await projectApi.create({ name, description, idea_id: ideaId })
state.projects.push(record)
return record
// 清除错误状态(供 toast 显示后重置,允许连续同值错误再次触发 watch)
function clearError() { state.error = null }
async function createProject(name: string, description = '', ideaId?: string, path?: string, stack?: string) {
try {
const record = await projectApi.create({ name, description, idea_id: ideaId, path, stack })
state.projects.push(record)
return record
} catch (e: any) {
state.error = e?.toString() ?? '创建项目失败'
return null
}
}
async function updateProject(id: string, field: string, value: string) {
@@ -52,9 +61,48 @@ export function useProjectStore() {
}
}
/** 重定位项目目录(后端重探测 stack,返回最新记录并更新本地状态) */
async function relocateProjectPath(id: string, newPath: string) {
const record = await projectApi.relocatePath(id, newPath)
const idx = state.projects.findIndex(p => p.id === id)
if (idx >= 0) state.projects[idx] = record
return record
}
async function deleteProject(id: string) {
await projectApi.delete(id)
state.projects = state.projects.filter(p => p.id !== id)
try {
await projectApi.delete(id) // 软删 → 回收站(可恢复)
state.projects = state.projects.filter(p => p.id !== id)
} catch (e: any) {
state.error = e?.toString() ?? '删除项目失败'
}
}
async function loadDeletedProjects() {
try {
state.deletedProjects = await projectApi.listDeleted()
} catch (e: any) {
state.error = e?.toString() ?? '加载回收站失败'
}
}
async function restoreProject(id: string) {
try {
await projectApi.restore(id)
state.deletedProjects = state.deletedProjects.filter(p => p.id !== id)
await loadProjects() // 恢复后刷新活跃列表
} catch (e: any) {
state.error = e?.toString() ?? '恢复项目失败'
}
}
async function purgeProject(id: string) {
try {
await projectApi.purge(id) // 彻底删(级联物理删,不可恢复)
state.deletedProjects = state.deletedProjects.filter(p => p.id !== id)
} catch (e: any) {
state.error = e?.toString() ?? '彻底删除失败'
}
}
// ── 任务 CRUD ──
@@ -67,9 +115,14 @@ export function useProjectStore() {
}
async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string }) {
const record = await taskApi.create(input)
state.tasks.push(record)
return record
try {
const record = await taskApi.create(input)
state.tasks.push(record)
return record
} catch (e: any) {
state.error = e?.toString() ?? '创建任务失败'
return null
}
}
async function updateTask(id: string, field: string, value: string) {
@@ -81,8 +134,12 @@ export function useProjectStore() {
}
async function deleteTask(id: string) {
await taskApi.delete(id)
state.tasks = state.tasks.filter(t => t.id !== id)
try {
await taskApi.delete(id)
state.tasks = state.tasks.filter(t => t.id !== id)
} catch (e: any) {
state.error = e?.toString() ?? '删除任务失败'
}
}
// ── 想法 CRUD ──
@@ -95,9 +152,14 @@ export function useProjectStore() {
}
async function createIdea(input: { title: string; description?: string; priority?: number; tags?: string; source?: string }) {
const record = await ideaApi.create(input)
state.ideas.push(record)
return record
try {
const record = await ideaApi.create(input)
state.ideas.push(record)
return record
} catch (e: any) {
state.error = e?.toString() ?? '创建想法失败'
return null
}
}
async function updateIdea(id: string, field: string, value: string) {
@@ -109,8 +171,25 @@ export function useProjectStore() {
}
async function deleteIdea(id: string) {
await ideaApi.delete(id)
state.ideas = state.ideas.filter(i => i.id !== id)
try {
await ideaApi.delete(id)
state.ideas = state.ideas.filter(i => i.id !== id)
} catch (e: any) {
state.error = e?.toString() ?? '删除想法失败'
}
}
async function evaluateIdea(id: string) {
const record = await ideaApi.evaluate(id)
const idx = state.ideas.findIndex(i => i.id === id)
if (idx >= 0) state.ideas[idx] = record
return record
}
async function promoteIdea(id: string) {
const res = await ideaApi.promote(id)
await loadIdeas() // 后端已回写 status=promoted/promoted_to刷新列表
return res
}
// ── 工作流 ──
@@ -129,11 +208,14 @@ export function useProjectStore() {
async function startEventListener() {
if (_eventUnlisten) return _eventUnlisten
_eventUnlisten = await workflowApi.onEvent((payload) => {
state.liveEvents.push(payload)
// 处理人工审批请求
if (payload.event.type === 'HumanApprovalRequest') {
state.pendingApproval = payload.event.data as typeof state.pendingApproval
try {
state.liveEvents.push(payload)
// 处理人工审批请求
if (payload.event?.type === 'HumanApprovalRequest') {
state.pendingApproval = payload.event.data as typeof state.pendingApproval
}
} catch (e) {
console.error('处理工作流事件失败:', e, payload)
}
})
return _eventUnlisten
@@ -182,20 +264,24 @@ export function useProjectStore() {
}))
return reactive({
// reactive state — 直接引用 state 属性,已经是响应式的
projects: state.projects,
tasks: state.tasks,
ideas: state.ideas,
workflowExecutions: state.workflowExecutions,
liveEvents: state.liveEvents,
loading: state.loading,
error: state.error,
// 状态用 getter 实时读 state — 直接 ideas: state.ideas 会快照引用,
// loadIdeas 等重新赋值 state.ideas 时返回对象的 ideas 属性不跟随,刷新后视图为空
get projects() { return state.projects },
get tasks() { return state.tasks },
get ideas() { return state.ideas },
get workflowExecutions() { return state.workflowExecutions },
get liveEvents() { return state.liveEvents },
get loading() { return state.loading },
get error() { return state.error },
clearError,
get deletedProjects() { return state.deletedProjects },
// project actions
loadProjects, createProject, updateProject, deleteProject,
loadProjects, createProject, updateProject, deleteProject, relocateProjectPath,
loadDeletedProjects, restoreProject, purgeProject,
// task actions
loadTasks, createTask, updateTask, deleteTask,
// idea actions
loadIdeas, createIdea, updateIdea, deleteIdea,
loadIdeas, createIdea, updateIdea, deleteIdea, evaluateIdea, promoteIdea,
// workflow actions
runWorkflow, loadWorkflowExecutions, startEventListener, stopEventListener, clearLiveEvents,
approveHumanApproval, pendingApproval: computed(() => state.pendingApproval),
@@ -203,3 +289,13 @@ export function useProjectStore() {
stats,
})
}
type ProjectStore = ReturnType<typeof createStore>
let _storeInstance: ProjectStore | null = null
/** 项目/任务/想法/工作流 全局状态(单例,多组件复用同一 reactive 包装) */
export function useProjectStore(): ProjectStore {
if (_storeInstance) return _storeInstance
_storeInstance = createStore()
return _storeInstance
}