新增: 初始化 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:
2026-06-12 01:31:05 +08:00
commit 98393b4908
178 changed files with 27859 additions and 0 deletions

509
src/stores/ai.ts Normal file
View 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,
}
}