新增: 初始化 DevFlow 项目仓库
Tauri 2 + Vue 3 + Vite 6 桌面应用,Rust workspace 含 13 个 crate (df-ai / df-storage / df-workflow / df-core / df-execute 等)。 核心能力:AI 聊天 agentic 循环(工具调用+人工审批)、工作流引擎、 任务/想法/项目/阶段管理、可追溯性,及配套前端组件。
This commit is contained in:
509
src/stores/ai.ts
Normal file
509
src/stores/ai.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
//! AI 聊天 Store — 管理对话状态、流式渲染、工具审批、对话管理
|
||||
|
||||
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'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
let _msgCounter = 0
|
||||
|
||||
const state = reactive({
|
||||
messages: [] as AiMessage[],
|
||||
streaming: false,
|
||||
currentText: '',
|
||||
pendingApprovals: [] as AiToolCallInfo[],
|
||||
// 正在生成的对话 id:生成中切走时用于路由,后台事件不污染当前视图
|
||||
generatingConvId: null as string | null,
|
||||
providers: [] as AiProviderConfig[],
|
||||
activeProvider: null as string | null,
|
||||
panelOpen: true,
|
||||
// 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
|
||||
maximized: true,
|
||||
// 对话管理
|
||||
conversations: [] as AiConversationSummary[],
|
||||
activeConversationId: null as string | null,
|
||||
sidebarOpen: false,
|
||||
// 窗口分离
|
||||
detached: false,
|
||||
// 吸附跟随中
|
||||
docked: false,
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
8
src/stores/index.ts
Normal file
8
src/stores/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useProjectStore } from './project'
|
||||
|
||||
export { useKnowledgeStore } from './knowledge'
|
||||
export type { KnowledgeItem } from './knowledge'
|
||||
|
||||
|
||||
export { useSettingsStore } from './settings'
|
||||
export type { AIProvider, Connection, GeneralSettings } from './settings'
|
||||
79
src/stores/knowledge.ts
Normal file
79
src/stores/knowledge.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
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: '🚀' },
|
||||
],
|
||||
})
|
||||
|
||||
export function useKnowledgeStore() {
|
||||
const items = computed(() => state.items)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
205
src/stores/project.ts
Normal file
205
src/stores/project.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { projectApi, taskApi, ideaApi, workflowApi } from '../api'
|
||||
import type { ProjectRecord, TaskRecord, IdeaRecord, WorkflowRecord, WorkflowEventPayload } from '../api/types'
|
||||
|
||||
// ── 全局响应式状态(单例) ──
|
||||
|
||||
const state = reactive({
|
||||
projects: [] as ProjectRecord[],
|
||||
tasks: [] as TaskRecord[],
|
||||
ideas: [] as IdeaRecord[],
|
||||
workflowExecutions: [] as WorkflowRecord[],
|
||||
liveEvents: [] as WorkflowEventPayload[],
|
||||
pendingApproval: null as {
|
||||
execution_id: string
|
||||
node_id: string
|
||||
title: string
|
||||
description: string
|
||||
options: string[]
|
||||
} | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
let _eventUnlisten: (() => void) | null = null
|
||||
|
||||
export function useProjectStore() {
|
||||
// ── 项目 CRUD ──
|
||||
async function loadProjects() {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
state.projects = await projectApi.list()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载项目失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string, description = '', ideaId?: string) {
|
||||
const record = await projectApi.create({ name, description, idea_id: ideaId })
|
||||
state.projects.push(record)
|
||||
return record
|
||||
}
|
||||
|
||||
async function updateProject(id: string, field: string, value: string) {
|
||||
await projectApi.update(id, field, value)
|
||||
const idx = state.projects.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
(state.projects[idx] as any)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(id: string) {
|
||||
await projectApi.delete(id)
|
||||
state.projects = state.projects.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// ── 任务 CRUD ──
|
||||
async function loadTasks(projectId?: string) {
|
||||
try {
|
||||
state.tasks = await taskApi.list(projectId)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载任务失败'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function updateTask(id: string, field: string, value: string) {
|
||||
await taskApi.update(id, field, value)
|
||||
const idx = state.tasks.findIndex(t => t.id === id)
|
||||
if (idx >= 0) {
|
||||
(state.tasks[idx] as any)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
// ── 想法 CRUD ──
|
||||
async function loadIdeas() {
|
||||
try {
|
||||
state.ideas = await ideaApi.list()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载想法失败'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function updateIdea(id: string, field: string, value: string) {
|
||||
await ideaApi.update(id, field, value)
|
||||
const idx = state.ideas.findIndex(i => i.id === id)
|
||||
if (idx >= 0) {
|
||||
(state.ideas[idx] as any)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIdea(id: string) {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
}
|
||||
|
||||
// ── 工作流 ──
|
||||
async function runWorkflow(name: string, dag: unknown, config?: Record<string, unknown>) {
|
||||
return await workflowApi.run(name, dag, config)
|
||||
}
|
||||
|
||||
async function loadWorkflowExecutions() {
|
||||
try {
|
||||
state.workflowExecutions = await workflowApi.listExecutions()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载工作流记录失败'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
return _eventUnlisten
|
||||
}
|
||||
|
||||
function clearLiveEvents() {
|
||||
state.liveEvents = []
|
||||
}
|
||||
|
||||
async function approveHumanApproval(decision: string, comment?: string) {
|
||||
if (!state.pendingApproval) return
|
||||
|
||||
try {
|
||||
// 调用 IPC 命令发送审批响应
|
||||
await invoke('approve_human_approval', {
|
||||
execution_id: state.pendingApproval.execution_id,
|
||||
node_id: state.pendingApproval.node_id,
|
||||
decision,
|
||||
comment
|
||||
})
|
||||
|
||||
// 清除待审批状态
|
||||
state.pendingApproval = null
|
||||
} catch (error) {
|
||||
console.error('审批失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function stopEventListener() {
|
||||
if (_eventUnlisten) {
|
||||
try {
|
||||
_eventUnlisten()
|
||||
} catch (e) {
|
||||
console.error('停止事件监听失败:', e)
|
||||
}
|
||||
_eventUnlisten = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 统计 ──
|
||||
const stats = computed(() => ({
|
||||
ideas: state.ideas.length,
|
||||
projects: state.projects.length,
|
||||
activeTasks: state.tasks.filter(t => t.status === 'in_progress').length,
|
||||
drafts: state.ideas.filter(i => i.status === 'draft').length,
|
||||
}))
|
||||
|
||||
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,
|
||||
// project actions
|
||||
loadProjects, createProject, updateProject, deleteProject,
|
||||
// task actions
|
||||
loadTasks, createTask, updateTask, deleteTask,
|
||||
// idea actions
|
||||
loadIdeas, createIdea, updateIdea, deleteIdea,
|
||||
// workflow actions
|
||||
runWorkflow, loadWorkflowExecutions, startEventListener, stopEventListener, clearLiveEvents,
|
||||
approveHumanApproval, pendingApproval: computed(() => state.pendingApproval),
|
||||
// computed
|
||||
stats,
|
||||
})
|
||||
}
|
||||
98
src/stores/settings.ts
Normal file
98
src/stores/settings.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
export interface AIProvider {
|
||||
id: number
|
||||
name: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
models: string[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number
|
||||
name: string
|
||||
type: 'mysql' | 'ssh' | 'redis' | 'mongo'
|
||||
typeLabel: string
|
||||
icon: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface GeneralSettings {
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
language: string
|
||||
dataDir: string
|
||||
autoExecute: boolean
|
||||
logLevel: 'error' | 'warn' | 'info' | 'debug'
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
aiProviders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Anthropic Claude',
|
||||
apiKey: 'sk-ant-••••••••••••••••7xKm',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
models: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'],
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Zhipu GLM',
|
||||
apiKey: '••••••••••••••••a3f2',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas',
|
||||
models: ['glm-4-plus', 'glm-4-flash'],
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'DeepSeek',
|
||||
apiKey: '••••••••••••••••9b8c',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
models: ['deepseek-chat', 'deepseek-coder'],
|
||||
isDefault: false,
|
||||
},
|
||||
] as AIProvider[],
|
||||
|
||||
connections: [
|
||||
{ id: 1, name: 'flux_dev', type: 'mysql' as const, typeLabel: 'MySQL', icon: '🗄️', host: '39.99.243.191', port: 3306, user: 'root', connected: true },
|
||||
{ id: 2, name: 'flux_dev', type: 'ssh' as const, typeLabel: 'SSH', icon: '🔐', host: '39.99.243.191', port: 22, user: 'root', connected: true },
|
||||
{ id: 3, name: 'flux_redis', type: 'redis' as const, typeLabel: 'Redis', icon: '⚡', host: '127.0.0.1', port: 6379, user: '', connected: true },
|
||||
{ id: 4, name: 'suke_dev', type: 'mongo' as const, typeLabel: 'MongoDB', icon: '🍃', host: '127.0.0.1', port: 27017, user: 'admin', connected: false },
|
||||
{ id: 5, name: 'rds_read', type: 'mysql' as const, typeLabel: 'MySQL', icon: '🗄️', host: 'rm-8vbrd2wil14hclop3vm.mysql.zhangbei.rds.aliyuncs.com', port: 3306, user: 'u_read', connected: true },
|
||||
] as Connection[],
|
||||
|
||||
general: {
|
||||
theme: 'dark' as const,
|
||||
language: 'zh-CN',
|
||||
dataDir: 'E:/wk-lab/devflow/data',
|
||||
autoExecute: false,
|
||||
logLevel: 'info' as const,
|
||||
} as GeneralSettings,
|
||||
})
|
||||
|
||||
export function useSettingsStore() {
|
||||
const aiProviders = computed(() => state.aiProviders)
|
||||
const connections = computed(() => state.connections)
|
||||
const general = computed(() => state.general)
|
||||
|
||||
const defaultProvider = computed(() =>
|
||||
state.aiProviders.find(p => p.isDefault)
|
||||
)
|
||||
|
||||
const connectionStats = computed(() => ({
|
||||
total: state.connections.length,
|
||||
connected: state.connections.filter(c => c.connected).length,
|
||||
}))
|
||||
|
||||
return {
|
||||
aiProviders,
|
||||
connections,
|
||||
general,
|
||||
defaultProvider,
|
||||
connectionStats,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user