新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地

This commit is contained in:
2026-06-16 12:41:13 +08:00
parent 212a927eee
commit 7d5cd4c89a
62 changed files with 4576 additions and 248 deletions

View File

@@ -278,6 +278,8 @@ const primaryNav = [
const secondaryNav = [
{ path: '/knowledge', label: 'nav.knowledge',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
{ path: '/audit', label: 'nav.audit',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' },
]
</script>

View File

@@ -15,11 +15,47 @@ export const aiApi = {
return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null })
},
/**
* 重新生成最后一条 AI 回复(UX-02)。
* 后端:占用 generating → 弹出末尾 AI 回复(保留触发它的 user 消息)→ 重跑 agentic loop。
* 前端消费:与 sendMessage 一样需本地 push 空气泡占位 + 置 streaming(见 useAiSend.regenerate)。
*/
regenerate(conversationId: string, language?: string): Promise<string> {
return invoke('ai_regenerate', { conversationId, language: language || 'zh-CN' })
},
/**
* 编辑最后一条 user 消息并重新生成(UX-09)。
* 后端:占用 generating → 替换末条 user content → 其后消息标 truncated(软删)→ 重跑 agentic loop。
* 前端消费:调 editMessage 前需移除被编辑消息之后的所有消息(视图截断)+ push 空气泡占位 + 置 streaming。
* 中间 user 消息编辑被后端拒绝(只允许末条)。
*/
editMessage(conversationId: string, newMessage: string, language?: string): Promise<string> {
return invoke('ai_chat_edit', { conversationId, newMessage, language: language || 'zh-CN' })
},
/** 批准/拒绝工具调用 */
approve(toolCallId: string, approved: boolean): Promise<string> {
return invoke('ai_approve', { toolCallId, approved })
},
/**
* 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
* run_agentic_loop(iteration 从 0 重计,再跑 max_iterations 轮)。
*/
continueLoop(conversationId: string): Promise<string> {
return invoke('ai_continue_loop', { conversationId })
},
/**
* 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态点停止)。
* 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。
*/
stopLoop(conversationId: string): Promise<string> {
return invoke('ai_stop_loop', { conversationId })
},
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用) */
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null }[]> {
return invoke('ai_pending_tool_calls', { convId })
@@ -74,6 +110,11 @@ export const aiApi = {
return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit })
},
/** 设置 Agentic 循环最大轮次运行时调整loop 入口锁定边界,热改下次发消息生效) */
setAgentMaxIterations(value: number): Promise<void> {
return invoke('ai_set_agent_max_iterations', { value })
},
/** 列出本机 Claude 技能skills + commands + plugins */
listSkills(): Promise<SkillInfo[]> {
return invoke('ai_list_skills')
@@ -120,4 +161,18 @@ export const aiApi = {
archiveConversation(conversationId: string, archived: boolean): Promise<void> {
return invoke('ai_conversation_archive', { conversationId, archived })
},
/** 置顶/取消置顶对话(UX-17) */
setPinnedConversation(conversationId: string, pinned: boolean): Promise<void> {
return invoke('ai_conversation_set_pinned', { conversationId, pinned })
},
/**
* 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
* @param format 'markdown' | 'json' | 'txt'
*/
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
return invoke('ai_conversation_export', { conversationId, format })
},
}

View File

@@ -125,7 +125,7 @@ export interface WorkflowRecord {
/** 工作流事件载荷(后端 emit 的结构) */
/** 工作流事件类型字面量联合(对应后端 WorkflowEvent serde tag=type rename_all=snake_caseR7收窄防写错 type 字符串) */
export type WorkflowEventType =
| 'node_started' | 'node_progress' | 'node_output' | 'node_completed' | 'node_failed'
| 'node_started' | 'node_progress' | 'node_output' | 'node_completed' | 'node_failed' | 'node_cancelled'
| 'workflow_paused' | 'workflow_completed' | 'workflow_failed'
| 'human_approval_request' | 'human_approval_response'
@@ -180,6 +180,13 @@ export interface AiProviderConfig {
updated_at: string
}
/**
* AI 错误分类字面量集(B-260615-42)。
* 与后端 `commands::ai::ErrorType` 的 serde(rename_all = "snake_case") 严格对齐。
* 前端消费方 switch 于此常量集做差异化提示(auth 引导填 key / network 提示连接 / 等)。
*/
export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
/** AI 聊天事件(后端 emit 的结构conversation_id 用于多对话流式路由) */
export type AiChatEvent = ({
type: 'AiTextDelta'; delta: string
@@ -194,11 +201,14 @@ export type AiChatEvent = ({
} | {
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number
} | {
type: 'AiError'; error: string
type: 'AiError'; error: string; error_type?: AiErrorType
} | {
type: 'AiAgentRound'; round: number
} | {
type: 'AiHeartbeat'
} | {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
type: 'AiMaxRoundsReached'
}) & {
conversation_id?: string
}
@@ -233,6 +243,7 @@ export interface AiConversationSummary {
provider_id: string | null
model: string | null
archived: boolean
pinned?: boolean
prompt_tokens?: number | null
completion_tokens?: number | null
created_at: string

View File

@@ -3,12 +3,25 @@ import { listen } from '@tauri-apps/api/event'
import type { WorkflowRecord, WorkflowEventPayload } from './types'
export const workflowApi = {
/** 触发工作流执行,返回 execution_id */
run(name: string, dag: unknown, config?: Record<string, unknown>): Promise<string> {
/**
* 触发工作流执行,返回 execution_id。
*
* F-260616-06 阶段2(②-2): taskId/targetStatus 可选,同时传时工作流完成/失败联动推进任务。
* B-260615-34:Tauri 2 IPC 不转 camelCase,snake_case 对齐后端 workflow.rs。
*/
run(
name: string,
dag: unknown,
config?: Record<string, unknown>,
taskId?: string,
targetStatus?: string,
): Promise<string> {
return invoke('run_workflow', {
name,
dag,
config: config ?? {},
task_id: taskId,
target_status: targetStatus,
})
},

File diff suppressed because it is too large Load Diff

View File

@@ -383,16 +383,15 @@ onBeforeUnmount(() => {
const parsed = computed(() => parseResult(props.tc.result))
/**
* id 项目名映射(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故同时查 projects deletedProjects,无则返回 undefined(调用方走 fallback)。
* 复用 project store 已加载列表,不新增网络请求。
* id 项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
* 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证(find 在 computed/渲染上下文中触发依赖追踪)
* 审批卡通常仅 1-2 行引用项目 id,find 比 computed 全量建 Map 更省。
*/
const projectNameById = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const p of projectStore.projects) map[p.id] = p.name
for (const p of projectStore.deletedProjects) if (!(p.id in map)) map[p.id] = p.name
return map
})
function projectNameById(id: string): string | undefined {
return projectStore.projects.find(p => p.id === id)?.name
?? projectStore.deletedProjects.find(p => p.id === id)?.name
}
/** 工具名 → 该工具中代表项目 id 的参数 key(仅项目类工具特化;delete_task 的 id 是任务 id,不在此列) */
const PROJECT_ID_TOOL_ARG: Record<string, 'id' | 'project_id'> = {
@@ -414,7 +413,7 @@ function displayArgValue(arg: { key: string; val: unknown }): string {
if (projectArgKey && arg.key === projectArgKey) {
const id = typeof arg.val === 'string' ? arg.val : ''
if (id) {
const name = projectNameById.value[id]
const name = projectNameById(id)
if (name) return t('aiTool.projectLabel', { name })
return t('aiTool.projectIdNotFound', { id })
}
@@ -438,7 +437,7 @@ function toolDisplayName(tc: AiToolCallInfo): string {
case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? argString(tc.args, idKey) : ''
const name = id ? projectNameById.value[id] : ''
const name = id ? projectNameById(id) : ''
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
return name ? `${t('aiTool.' + prefixKey)}${name}` : t('aiTool.' + fallbackKey)

View File

@@ -67,7 +67,9 @@ export async function switchConversation(id: string) {
}
state.messages = rawMsgs
.filter((m: any) => m.role !== 'tool')
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
.map((m: any, i: number) => ({
id: `loaded-${i}`,
role: m.role,
@@ -170,6 +172,15 @@ async function archiveConversation(id: string, archived: boolean) {
notifyConversationChanged()
}
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17) */
async function setPinnedConversation(id: string, pinned: boolean) {
await aiApi.setPinnedConversation(id, pinned)
// 本地同步置顶态(排序 computed 读 pinned,立即重排)
const conv = state.conversations.find(c => c.id === id)
if (conv) conv.pinned = pinned
notifyConversationChanged()
}
/** 折叠/展开归档分组 */
function toggleArchivedFold() {
state.archivedCollapsed = !state.archivedCollapsed
@@ -190,6 +201,7 @@ export function useAiConversations() {
deleteConversation,
renameConversation,
archiveConversation,
setPinnedConversation,
toggleArchivedFold,
toggleSidebar,
}

View File

@@ -10,6 +10,7 @@
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -18,7 +19,7 @@ import { nextMsgId } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
@@ -36,7 +37,8 @@ const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError'])
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -81,6 +83,15 @@ function clearAllToolSlowTimers(): void {
_toolSlowNotified.clear()
}
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true)。
//
// 不进 store.state(批次41 领地,且与 pendingApprovals 同性质是"待用户操作"信号,
// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出,
// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。
// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定),
// 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false)
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
emit('ai-conversation-changed', {})
@@ -186,6 +197,13 @@ export function handleEvent(event: AiChatEvent) {
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
break
case 'AiMaxRoundsReached':
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
pendingMaxRounds.value = true
break
case 'AiToolCallStarted': {
const info: AiToolCallInfo = {
id: event.id,
@@ -265,6 +283,7 @@ export function handleEvent(event: AiChatEvent) {
state.streaming = false
state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) {
state.lastTokenUsage = {
@@ -298,15 +317,20 @@ export function handleEvent(event: AiChatEvent) {
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
// AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
// 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
state.messages.push({
id: `err-${nextMsgId()}`,
role: 'assistant',
content: friendlyError(event.error),
isError: true,
errorType: event.error_type,
timestamp: Date.now(),
})
} as AiMessage)
break
}
}

View File

@@ -140,6 +140,135 @@ async function doSend(text: string, skill?: string, force = false) {
}
}
/**
* 重新生成最后一条 AI 回复(UX-02)。
*
* 与 doSend 的区别:
* - 不 push 新 user 消息(触发它的 user 消息已在历史末尾)
* - 仅 push 一个空 AI 气泡占位 + 置 streaming,后端弹出旧 AI 回复后重跑 loop
* - 失败回滚只移除占位气泡(不移除 user 消息)
*
* 后端 ai_regenerate 占用 generating 防并发双发,此处不再额外入队/预检。
*/
async function regenerate() {
// 末尾须有 AI 回复可重生成(后端也会校验,前端先挡避免空跑)
const msgs = state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'assistant') {
throw new Error(t('aiChat.regenerateUnavailable'))
}
// 移除末尾旧 AI 气泡(后端也弹 history;前端先弹避免占位前残留旧文),push 空气泡占位
state.messages.pop()
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
state.queue = [] // 重生成视同新发,清残留队列
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.regenerateUnavailable'))
}
try {
await aiApi.regenerate(convId, lang)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
}
}
/**
* 编辑最后一条 user 消息并重新生成(UX-09)。
*
* 与 regenerate 的区别:
* - regenerate:末条须是 assistant,pop 旧 AI 回复后重跑
* - editMessage:末条须是 user(中间编辑被后端拒绝),就地改其 content,截断其后所有消息,
* push 空气泡占位后重跑。被截断消息后端标 truncated(软删保留 DB),前端视图层同步移除。
*
* 后端 ai_chat_edit 占用 generating 防并发双发,此处不再额外入队/预检。
*
* @param newMessage 编辑后的新内容(替换末条 user 消息 content)
*/
async function editMessage(newMessage: string) {
if (!newMessage.trim()) return
// 末条须是 user(中间编辑语义复杂,拒绝);后端也会校验,前端先挡避免空跑
const msgs = state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'user') {
throw new Error(t('aiChat.editNotLastUser'))
}
// 截断末条 user 之后的消息(此时 last 即末条 user,其后应已有 assistant 回复或为空)
// 找到末条 user 的下标,移除其后所有消息
let lastUserIdx = -1
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break }
}
if (lastUserIdx < 0) {
throw new Error(t('aiChat.editNotLastUser'))
}
// 截断其后(保留末条 user 本身,改其 content)
state.messages.splice(lastUserIdx + 1)
msgs[lastUserIdx].content = newMessage.trim()
// push 空气泡占位(新 AI 回复将流入)
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
state.queue = [] // 编辑重生成视同新发,清残留队列
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const convId = state.activeConversationId
if (!convId) {
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.editNotLastUser'))
}
try {
await aiApi.editMessage(convId, newMessage.trim(), lang)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
}
}
/** 取出队首并发送AiCompleted 触发,此时 streaming 已 false */
export function drainQueue() {
if (state.queue.length === 0) return
@@ -281,6 +410,8 @@ async function stopChat() {
export function useAiSend() {
return {
sendMessage,
regenerate,
editMessage,
approveToolCall,
batchApprove,
drainQueue,

View File

@@ -6,9 +6,15 @@ export default {
doubleClickToRename: 'Double-click to rename',
archive: 'Archive',
unarchive: 'Unarchive',
pinConversation: 'Pin',
unpinConversation: 'Unpin',
archived: 'Archived',
archivedCount: 'Archived ({n})',
emptyConversation: 'No conversations',
// UX-06 §3.1: conversation search (live title filter, Ctrl+K to focus)
searchPlaceholder: 'Search chats…',
searchEmpty: 'No matching chats',
searchShortcutHint: 'Ctrl+K',
// ── Time grouping (sidebar) ──
today: 'Today',
@@ -62,10 +68,17 @@ export default {
// ── Skill ──
clearSkill: 'Clear skill',
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
placeholderDefault: 'Type a message... (/ for skills)',
placeholderDefault: 'Type a message... (/ for skills, @ to mention)',
skillNoMatch: 'No matching skill',
skillNotLoaded: 'No local skills loaded',
// ── UX-10: @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
mentionHint: '@ to mention project/task',
mentionGroupProject: 'Projects',
mentionGroupTask: 'Tasks',
mentionNoMatch: 'No matching entity',
mentionEmpty: 'No project/task to mention',
// ── Send ──
stopGenerating: 'Stop generating',
@@ -76,11 +89,43 @@ export default {
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
clearChat: 'Clear',
confirmClearChat: 'Are you sure you want to clear all messages in this chat? This cannot be undone.',
// UX-08 §3.4: generate-in-progress confirm when starting a new chat (only when generating, otherwise no behavior change)
confirmNewConvWhileGenerating: 'Generation is in progress. Interrupt it and start a new chat?',
toastSendFail: 'Send failed: {msg}',
// ── Message copy (F-260615-13) ──
copyMsg: 'Copy message',
copied: 'Copied',
copyFailed: 'Copy failed',
// ── Message actions bar (UX-02) ──
regenerate: 'Regenerate',
regenerateFailed: 'Regenerate failed: {msg}',
regenerateUnavailable: 'No reply to regenerate',
// ── UX-09: edit last user message and regenerate ──
editMessage: 'Edit',
editMessageFailed: 'Edit failed: {msg}',
editNotLastUser: 'Only the last user message can be edited',
placeholderEditing: 'Edit message then Enter to regenerate (Esc to cancel)',
// ── Error bubble actions (UX-03) ──
retry: 'Retry',
goToSettingsAction: 'Open settings',
// ── F-260616-03: max iterations reached pause action card ──
maxRoundsReached: 'Max rounds reached. Continue?',
maxRoundsHint: 'Continue runs another full round; stop ends with current results',
continueLoop: 'Continue',
stopLoop: 'Stop',
continueLoopFailed: 'Continue failed: {msg}',
stopLoopFailed: 'Stop failed: {msg}',
// ── UX-18: conversation export (sidebar hover, pick format to download) ──
exportConversation: 'Export chat',
exportMarkdown: 'Markdown',
exportJson: 'JSON',
exportTxt: 'Plain text',
exportFailed: 'Export failed: {msg}',
},
}

View File

@@ -4,7 +4,8 @@ export default {
add: '+ New Knowledge',
tab: {
library: 'Library',
inbox: 'Inbox',
// inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a)
inbox: 'Pending',
},
searchPlaceholder: 'Search title, tags, content...',
categoryAll: 'All',

View File

@@ -5,6 +5,7 @@ export default {
projects: 'Projects',
tasks: 'Tasks',
knowledge: 'Knowledge',
audit: 'Audit Log',
settings: 'Settings',
workspace: 'Workspace',
traceability: 'Traceability',

View File

@@ -76,6 +76,9 @@ export default {
labelPerConvConcurrency: 'Per-conversation concurrency',
descPerConvConcurrency: 'Concurrent LLM calls within one conversation (main loop / title / distill shared)',
labelAgentMaxIterations: 'Agent max iterations',
descAgentMaxIterations: 'Max rounds of the agentic loop (stream → tool → feedback) (1-50, default 10); changes take effect on the next message',
// ===== Knowledge base panel =====
panelKnowledge: '📚 Knowledge base',
labelAutoExtract: 'Auto-distill knowledge',

View File

@@ -5,7 +5,7 @@ export default {
refresh: 'Refresh',
// Status hints
loading: 'Loading...',
loadFailed: 'Failed to load',
loadFailed: 'Failed to load: {msg}',
// Panel
infoTitle: 'Task Info',
// Field labels
@@ -36,6 +36,16 @@ export default {
cancel: 'Cancel Task',
},
advancing: 'Advancing...',
advanceFailed: 'Failed to advance task',
advanceFailed: 'Failed to advance task: {msg}',
// F-260616-06 ①-1 / B-41 Workflow advance (linked to task, auto-selects pipeline template by target_status)
workflowAdvanceTitle: 'Workflow Advance',
workflowAdvance: 'Run Workflow',
workflowAdvancing: 'Workflow advancing...',
workflowAdvanceFailed: 'Workflow advance failed: {msg}',
// B-41 Inline progress (lightweight, listens to NodeStarted/NodeCompleted/WorkflowFailed)
workflowStepRunning: 'Running: {node}',
workflowStepsProgress: '{done} / {total} steps done',
workflowCompletedHint: 'Workflow completed, task advanced',
workflowFailedHint: 'Workflow failed, task rolled back',
},
}

View File

@@ -6,9 +6,15 @@ export default {
doubleClickToRename: '双击重命名',
archive: '归档',
unarchive: '取消归档',
pinConversation: '置顶',
unpinConversation: '取消置顶',
archived: '已归档',
archivedCount: '已归档 ({n})',
emptyConversation: '暂无对话',
// UX-06 §3.1:对话搜索(实时过滤标题,Ctrl+K 聚焦)
searchPlaceholder: '搜索对话…',
searchEmpty: '无匹配对话',
searchShortcutHint: 'Ctrl+K',
// ── 时间分组(侧栏) ──
today: '今天',
@@ -62,10 +68,17 @@ export default {
// ── 技能 ──
clearSkill: '取消技能',
placeholderWithSkill: '已选技能,输入参数后回车',
placeholderDefault: '输入消息.../ 触发技能)',
placeholderDefault: '输入消息.../ 触发技能@ 引用实体',
skillNoMatch: '无匹配技能',
skillNotLoaded: '未读取到本机技能',
// ── UX-10: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
mentionHint: '@ 引用项目/任务',
mentionGroupProject: '项目',
mentionGroupTask: '任务',
mentionNoMatch: '无匹配实体',
mentionEmpty: '暂无可引用的项目/任务',
// ── 发送 ──
stopGenerating: '停止生成',
@@ -76,11 +89,43 @@ export default {
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
clearChat: '清空',
confirmClearChat: '确定清空当前对话的所有消息?此操作不可恢复。',
// UX-08 §3.4:生成中点新建对话确认弹窗(仅生成中触发,非生成中零行为变化)
confirmNewConvWhileGenerating: '当前正在生成,确定中断并新建对话?',
toastSendFail: '发送失败:{msg}',
// ── 消息拷贝(F-260615-13) ──
copyMsg: '复制消息',
copied: '已复制',
copyFailed: '复制失败',
// ── 消息操作栏(UX-02) ──
regenerate: '重新生成',
regenerateFailed: '重新生成失败:{msg}',
regenerateUnavailable: '没有可重新生成的回复',
// ── UX-09: 编辑末条 user 消息并重新生成 ──
editMessage: '编辑',
editMessageFailed: '编辑失败:{msg}',
editNotLastUser: '只能编辑最后一条用户消息',
placeholderEditing: '编辑消息后回车重新生成Esc 取消)',
// ── 错误气泡操作入口(UX-03) ──
retry: '重试',
goToSettingsAction: '去设置',
// ── F-260616-03: 达 max_iterations 暂停态操作卡 ──
maxRoundsReached: '已达最大轮次,是否继续?',
maxRoundsHint: '继续将再跑一轮上限;停止则按当前结果收尾',
continueLoop: '继续',
stopLoop: '停止',
continueLoopFailed: '继续失败:{msg}',
stopLoopFailed: '停止失败:{msg}',
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
exportConversation: '导出对话',
exportMarkdown: 'Markdown',
exportJson: 'JSON',
exportTxt: '纯文本',
exportFailed: '导出失败:{msg}',
},
}

View File

@@ -5,14 +5,15 @@ export default {
// 顶层 Tab
tab: {
library: '知识库',
inbox: '审核收件箱',
// inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a)
inbox: '待处理',
},
// 搜索
searchPlaceholder: '搜索标题、标签、内容...',
// 分类
categoryAll: '全部',
// 列表空状态
emptyInbox: '✅ 暂无待审核条目',
emptyInbox: '✅ 暂无待处理条目',
emptyLibrary: '📭 没有匹配的知识',
// 详情头部操作
edit: '✏️ 编辑',

View File

@@ -5,6 +5,7 @@ export default {
projects: '项目',
tasks: '任务',
knowledge: '知识库',
audit: '审批历史',
settings: '设置',
workspace: '工作区',
traceability: '追溯',

View File

@@ -76,6 +76,9 @@ export default {
labelPerConvConcurrency: '单对话并发上限',
descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)',
labelAgentMaxIterations: 'Agent 最大轮次',
descAgentMaxIterations: 'Agentic 循环流式→工具→回传的最大轮次1-50默认 10热改下次发消息生效',
// ===== 知识库面板 =====
panelKnowledge: '📚 知识库',
labelAutoExtract: '自动提炼知识',

View File

@@ -5,7 +5,7 @@ export default {
refresh: '刷新',
// 状态提示
loading: '加载中...',
loadFailed: '加载失败',
loadFailed: '加载失败: {msg}',
// 面板
infoTitle: '任务信息',
// 字段标签
@@ -36,6 +36,16 @@ export default {
cancel: '取消任务', // * → cancelled
},
advancing: '推进中...',
advanceFailed: '推进任务失败',
advanceFailed: '推进任务失败: {msg}',
// F-260616-06 ①-1 / B-41 工作流推进(联动任务,按 target_status 自动选推进链模板)
workflowAdvanceTitle: '工作流推进',
workflowAdvance: '工作流推进', // 按钮(默认入口)
workflowAdvancing: '工作流推进中...',
workflowAdvanceFailed: '工作流推进失败: {msg}',
// B-41 内嵌进度(轻量,听 NodeStarted/NodeCompleted/WorkflowFailed)
workflowStepRunning: '执行中: {node}',
workflowStepsProgress: '已完成 {done} / {total} 步',
workflowCompletedHint: '工作流完成,任务已推进',
workflowFailedHint: '工作流失败,任务已退回',
},
}

View File

@@ -59,6 +59,12 @@ const routes = [
component: () => import('../views/Knowledge.vue'),
meta: { title: '知识库', icon: 'icon-book' },
},
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
meta: { title: '审批历史', icon: 'icon-audit' },
},
{
path: '/settings',
name: 'Settings',

View File

@@ -19,7 +19,7 @@
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
//! - state 为模块级单例,全应用共享同一引用
import { reactive, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '@/api/types'
/**
@@ -73,6 +73,9 @@ export const state = reactive({
agentRound: 0,
// 归档分组折叠态(默认折叠)
archivedCollapsed: true,
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
searchQuery: '',
// 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,
@@ -101,6 +104,25 @@ watch(
},
)
/**
* 对话搜索过滤结果(UX-06 §3.1)。
*
* 行为:
* - searchQuery 为空 → 返回 null(信号:消费方走原时间分组 groupedActive / 归档分组)。
* - 非空 → 在全部对话(活跃+归档)中按标题做 case-insensitive 子串匹配,平铺返回
* (取消分组),按 updated_at DESC 排序,最近改过的在最前。
*
* 设计:过滤逻辑放 store 而非组件,供主窗口/分离窗口两处侧栏复用同一结果;
* 消费方仅需 `store.filteredConversations` 即得数组或 null,UI 连接在侧栏视图内完成。
*/
const filteredConversations = computed<AiConversationSummary[] | null>(() => {
const q = state.searchQuery.trim().toLowerCase()
if (!q) return null
return state.conversations
.filter(c => (c.title ?? '').toLowerCase().includes(q))
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0))
})
/**
* AI Store 统一入口 — 返回单例 state + 全部方法。
*
@@ -110,6 +132,7 @@ watch(
export function useAiStore() {
return {
state,
filteredConversations,
...useAiEvents(),
...useAiStream(),
...useAiSend(),

View File

@@ -55,7 +55,9 @@ export function parseTags(tags: string | null): string[] {
}
export function useKnowledgeStore() {
// ── 加载列表(默认排除 archived) ──
// ── 加载列表(library:默认仅 published,F-260616-02 决策 a) ──
// 后端 knowledge_list 默认收窄为 published,无需前端传 status。
// 显式 status 仍透传(如 status='archived' 查归档)。
async function loadList(status?: string) {
const seq = ++_itemsReqSeq
state.loading = true
@@ -72,7 +74,8 @@ export function useKnowledgeStore() {
}
}
// ── 加载审核收件箱(候选列表) ──
// ── 加载待处理收件箱(candidate + pending_review,F-260616-02 决策 a) ──
// 后端 knowledge_list_candidates 已收两种待处理状态,前端调用不变。
async function loadCandidates() {
const seq = ++_itemsReqSeq
try {

View File

@@ -12,8 +12,18 @@ const t = ((i18n as any).global.t as (k: string) => string).bind((i18n as any).g
* src/api/workflow.ts,本子 store 不再直接 invoke IPC。
*/
export function createWorkflowStore() {
async function runWorkflow(name: string, dag: unknown, config?: Record<string, unknown>) {
return await workflowApi.run(name, dag, config)
/**
* 触发工作流执行。
* F-260616-06 阶段2(②-2): taskId/targetStatus 可选,同时传时工作流完成/失败联动推进任务。
*/
async function runWorkflow(
name: string,
dag: unknown,
config?: Record<string, unknown>,
taskId?: string,
targetStatus?: string,
) {
return await workflowApi.run(name, dag, config, taskId, targetStatus)
}
async function loadWorkflowExecutions() {

311
src/views/AuditLog.vue Normal file
View File

@@ -0,0 +1,311 @@
<template>
<div class="audit-log">
<header class="page-header">
<h1>审批历史</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="reload">刷新</button>
</div>
</header>
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)参数与结果仅展示摘要,完整数据留存本地数据库</p>
<!-- 状态条 -->
<div v-if="loading" class="empty-state">加载中</div>
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
<div v-else-if="records.length === 0" class="empty-state">暂无审计记录</div>
<!-- 表格 -->
<div v-else class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th class="col-time">请求时间</th>
<th class="col-tool">工具</th>
<th class="col-risk">风险</th>
<th class="col-status">状态</th>
<th class="col-decided">决策者</th>
<th class="col-args">参数摘要</th>
<th class="col-result">结果摘要</th>
</tr>
</thead>
<tbody>
<tr v-for="r in records" :key="r.id">
<td class="col-time">
<div class="time-rel">{{ formatRelativeZh(r.requested_at) }}</div>
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
</td>
<td class="col-tool"><code class="tool-name">{{ r.tool_name }}</code></td>
<td class="col-risk">
<span class="risk-badge" :class="riskClass(r.risk_level)">{{ riskLabel(r.risk_level) }}</span>
</td>
<td class="col-status">
<span class="status-tag" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td class="col-decided">
<span v-if="r.decided_by" class="decided-tag" :class="decidedClass(r.decided_by)">{{ decidedLabel(r.decided_by) }}</span>
<span v-else class="decided-none"></span>
</td>
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="!loading && !errorMsg" class="pager">
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">上一页</button>
<span class="pager-info"> {{ page }} {{ hasMore ? '' : '(末页)' }}</span>
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">下一页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { formatRelativeZh, formatDate } from '@/utils/time'
// 本地 interface(不进 api/types.ts,避撞 B-42 agent)
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应
interface ToolExecutionRecord {
id: string
conversation_id: string | null
tool_call_id: string
tool_name: string
arguments_brief: string
result_brief: string | null
status: string
risk_level: string
requested_at: string
executed_at: string | null
decided_by: string | null
}
const PAGE_SIZE = 50
const records = ref<ToolExecutionRecord[]>([])
const offset = ref(0)
const loading = ref(false)
const errorMsg = ref('')
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
const hasMore = computed(() => records.value.length === PAGE_SIZE)
async function load() {
loading.value = true
errorMsg.value = ''
try {
const list = await invoke<ToolExecutionRecord[]>('list_tool_executions', {
limit: PAGE_SIZE,
offset: offset.value,
})
records.value = list
// 下一页拉到空数组 → 应回退到上一页(避免停在空页)
if (list.length === 0 && offset.value > 0) {
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
} catch (e) {
errorMsg.value = String(e)
} finally {
loading.value = false
}
}
function reload() {
offset.value = 0
void load()
}
function prevPage() {
if (offset.value === 0) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
void load()
}
function nextPage() {
offset.value += PAGE_SIZE
void load()
}
// ── 标签 / 样式映射 ──
function riskLabel(r: string): string {
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? r
}
function riskClass(r: string): string {
return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low'
}
function statusLabel(s: string): string {
return ({
pending: '待审批',
approved: '已批准',
rejected: '已拒绝',
executing: '执行中',
completed: '已完成',
failed: '失败',
} as Record<string, string>)[s] ?? s
}
function statusClass(s: string): string {
return ({
pending: 'status-pending',
approved: 'status-approved',
rejected: 'status-rejected',
executing: 'status-executing',
completed: 'status-completed',
failed: 'status-failed',
} as Record<string, string>)[s] ?? 'status-pending'
}
function decidedLabel(d: string): string {
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d
}
function decidedClass(d: string): string {
return d === 'human' ? 'decided-human' : 'decided-auto'
}
onMounted(() => {
void load()
})
</script>
<style scoped>
.audit-log {
padding: var(--df-pad-page, 24px);
max-width: 1200px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-page);
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
.header-actions { display: flex; gap: 10px; }
.page-desc {
font-size: 12px;
color: var(--df-text-dim);
margin-bottom: 16px;
}
/* ===== 表格 ===== */
.audit-table-wrap {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
overflow: auto;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.audit-table thead th {
text-align: left;
padding: 10px 12px;
font-weight: 500;
font-size: 11px;
color: var(--df-text-dim);
border-bottom: 0.5px solid var(--df-border);
white-space: nowrap;
position: sticky;
top: 0;
background: var(--df-bg-card);
}
.audit-table tbody td {
padding: 8px 12px;
border-bottom: 0.5px solid var(--df-border);
color: var(--df-text-secondary);
vertical-align: top;
}
.audit-table tbody tr:last-child td { border-bottom: none; }
.audit-table tbody tr:hover td { background: var(--df-sidebar-hover, rgba(255,255,255,0.03)); }
.col-time { min-width: 120px; }
.col-tool { min-width: 130px; }
.col-risk { width: 56px; }
.col-status { width: 80px; }
.col-decided { width: 64px; }
.col-args { min-width: 200px; }
.col-result { min-width: 220px; }
.time-rel { font-size: 12px; color: var(--df-text-secondary); }
.time-abs { font-size: 10px; color: var(--df-text-dim); margin-top: 2px; font-family: var(--df-font-mono); }
.tool-name {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-accent);
background: var(--df-accent-bg, rgba(123,111,240,0.08));
padding: 1px 6px;
border-radius: var(--df-radius-xs);
}
.brief {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-text-secondary);
word-break: break-all;
white-space: pre-wrap;
}
/* 风险 badge */
.risk-badge {
display: inline-block;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
}
.risk-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.risk-medium { background: rgba(255,152,0,0.2); color: #ff9800; }
.risk-high { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 状态 tag */
.status-tag {
display: inline-block;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: var(--df-radius-sm);
white-space: nowrap;
}
.status-pending { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-approved { background: rgba(76,175,80,0.2); color: var(--df-success, #4caf50); }
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
.status-executing { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-completed { background: rgba(76,175,80,0.15); color: var(--df-success, #4caf50); }
.status-failed { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 决策者 tag */
.decided-tag {
display: inline-block;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
}
.decided-human { background: rgba(123,111,240,0.2); color: var(--df-accent); }
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.decided-none { color: var(--df-text-dim); }
/* ===== 分页 ===== */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 20px;
}
.pager-info {
font-size: 12px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
.empty-state {
text-align: center;
padding: 60px 20px;
font-size: 14px;
color: var(--df-text-dim);
}
</style>

View File

@@ -266,6 +266,17 @@
@change="syncConcurrencyConfig" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAgentMaxIterations') }}</span>
<span class="setting-desc">{{ $t('settings.descAgentMaxIterations') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" max="50" style="width:80px"
v-model.number="settings.agentMaxIterations"
@change="syncAgentMaxIterations" />
</div>
</div>
</div>
</section>
@@ -613,6 +624,8 @@ const settings = reactive({
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
// F-260616-01: Agentic 循环最大轮次,默认 10与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐)
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
})
function applyTheme(theme: string) {
@@ -647,6 +660,10 @@ watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v)
})
watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
})
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
@@ -669,6 +686,24 @@ function syncConcurrencyConfig() {
}, 300)
}
// 同步 Agentic 最大轮次到后端(debounce 300ms)
// F-260616-01: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
// 下次发消息才生效(与 llm_concurrency 传 Arc 实时反映的区别)。
// 双 clamp此处 1-50 + 后端 command 再 clamp 1-50防越界输入致 loop 失效/失控
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxIterations() {
// clamp 1-50防越界(过小致 agent 失能、过大致烧 token)
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
if (_agentIterTimer) clearTimeout(_agentIterTimer)
_agentIterTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
} catch (e) {
console.error('同步 Agent 最大轮次失败:', e)
}
}, 300)
}
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
@@ -735,11 +770,14 @@ onMounted(() => {
loadKnowledgeConfig()
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
syncAgentMaxIterations()
})
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
if (_agentIterTimer) clearTimeout(_agentIterTimer)
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
if (_toastTimer) clearTimeout(_toastTimer)
})

View File

@@ -63,6 +63,32 @@
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</div>
<!-- F-260616-06 -1 / B-41 工作流推进按钮(联动任务, target_status 后端自动选推进链模板)
与手动 advance 并存:仅当当前态的 primary 前向推进目标 {in_progress, testing, done}
( template_for 有对应模板)时显示手动 advance 仍走 advance_task IPC;工作流推进
run_workflow, AiNode/HumanNode 执行后再由后端回调推进任务
独立 loading 标志 wfAdvancing advancing 互不干扰 -->
<div v-if="wfAdvanceAction" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.workflowAdvanceTitle') }}</span>
<div class="advance-actions">
<button
type="button"
class="btn btn-sm btn-primary"
:disabled="wfAdvancing || advancing"
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
</div>
<!-- B-41 轻量进度(监听 workflow-event: NodeStarted/NodeCompleted/WorkflowFailed)
完整进度条/预估留后续批;完成/失败后任务态由后端回调推进,df-data-changed 自动刷新 task -->
<div v-if="wfAdvancing || wfProgressHint" class="wf-progress">
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-else-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value">
@@ -119,6 +145,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { taskApi, projectApi } from '@/api'
import { workflowApi } from '@/api'
import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown'
import {
@@ -127,7 +154,7 @@ import {
priorityLabel,
priorityClass,
} from '../constants/project'
import type { TaskRecord, ProjectRecord, DfDataChangedPayload } from '@/api/types'
import type { TaskRecord, ProjectRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
const { t } = useI18n()
const route = useRoute()
@@ -138,6 +165,24 @@ const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([])
const advancing = ref(false)
// ============================================================
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
// ------------------------------------------------------------
// wfAdvancing:推进中态;wfExecId:当前监听的工作流执行 ID(过滤 workflow-event);
// wfTotalNodes/wfDoneCount/wfRunningNode:轻量进度(听 NodeStarted/NodeCompleted);
// wfResult: 'completed' | 'failed' | null —— 终态提示,完成/失败后自动清空(由新一次推进重置)。
const wfAdvancing = ref(false)
const wfExecId = ref<string | null>(null)
const wfTotalNodes = ref(0)
const wfDoneCount = ref(0)
const wfRunningNode = ref<string>('')
const wfResult = ref<'completed' | 'failed' | null>(null)
const wfProgressHint = computed(() => wfResult.value !== null)
const wfCompletedHint = computed(() => wfResult.value === 'completed')
const wfFailedHint = computed(() => wfResult.value === 'failed')
const wfDoneTotal = computed(() => wfTotalNodes.value)
const taskId = computed(() => route.params.id as string)
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
@@ -206,6 +251,28 @@ const advanceActions = computed<AdvanceAction[]>(() => {
return ADVANCE_MAP[s] ?? []
})
// F-260616-06 ①-1: 工作流推进按钮的目标态。
// 仅当当前态的 primary(前向推进)目标 ∈ template_for 支持的三态 {in_progress, testing, done} 时显示。
// 非前向(退回/阻塞/取消)不走工作流(无对应模板,且语义上工作流只做前向推进)。
// ADVANCE_MAP 各行的首个 primary 项:todo→in_progress, in_review→testing, testing→done, blocked→in_progress
// in_progress 的 primary 是 in_review(无模板,不显示工作流推进按钮)。
const WF_SUPPORTED_TARGETS = new Set(['in_progress', 'testing', 'done'])
// CR-08-O1: blocked 是阻塞态(任务被阻塞),其 primary=in_progress 虽 ∈ WF_SUPPORTED_TARGETS,
// 但 template_for('in_progress') 是「todo→in_progress 从头执行」拓扑(AiNode 等重新跑一遍),
// blocked 恢复语义不该重跑 AiNode(任务已有上下文/进度,只需恢复非从头执行)。
// 最小修法:blocked 不显示工作流推进按钮,用户手动 advance(blocked→in_progress 走后端状态机)或先解除阻塞。
// (引入 blocked 恢复模板需后端 template_for 扩展,复杂度高,非阻断 med 项留待 UX 决策。)
const WF_EXCLUDED_FROM = new Set(['blocked'])
const wfAdvanceAction = computed<AdvanceAction | null>(() => {
const s = task.value?.status
if (!s) return null
if (WF_EXCLUDED_FROM.has(s)) return null
const actions = ADVANCE_MAP[s] ?? []
const primary = actions.find(a => a.variant === 'btn-primary')
if (!primary) return null
return WF_SUPPORTED_TARGETS.has(primary.target) ? primary : null
})
async function handleAdvance(target: string) {
if (!task.value || advancing.value) return
advancing.value = true
@@ -215,12 +282,87 @@ async function handleAdvance(target: string) {
task.value = updated
errorMsg.value = ''
} catch (e: any) {
errorMsg.value = e?.toString() ?? t('taskDetail.advanceFailed')
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
} finally {
advancing.value = false
}
}
// F-260616-06 ①-1: 工作流推进 — 传空 dag + target_status,后端自动选推进链模板。
// B-41: 监听 workflow-event 显示轻量进度;完成/失败后任务态由后端回调推进(②-3/②-4),
// df-data-changed 自动刷新 task(AR-11 已通),此处不直接改 task.value。
// name 用推进链标识(模板内未硬编码 task_id,运行时由后端 config 注入,前端只传 topology hint)。
async function handleWorkflowAdvance(target: string) {
if (!task.value || wfAdvancing.value || advancing.value) return
wfAdvancing.value = true
wfResult.value = null
wfRunningNode.value = ''
wfDoneCount.value = 0
wfTotalNodes.value = 0
try {
const execId = await workflowApi.run(
`task-advance:${target}`,
{}, // 空 dag → 后端 template_for(target) 选模板
{}, // 全局 config 留空(节点级 config 由模板/运行时注入)
task.value.id,
target,
)
wfExecId.value = execId
errorMsg.value = ''
} catch (e: any) {
wfAdvancing.value = false
wfExecId.value = null
errorMsg.value = t('taskDetail.workflowAdvanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
}
}
// B-41: 处理单个工作流事件(由 onEvent 回调 dispatch)。按 execution_id 过滤当前推进。
function handleWorkflowEvent(payload: WorkflowEventPayload) {
if (!wfExecId.value || payload.execution_id !== wfExecId.value) return
const evt = payload.event
switch (evt?.type) {
case 'node_started': {
// 第一次 NodeStarted 时初始化 total(后端模板节点数,无法前端预知,
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
const node = String(evt.node_id ?? '')
wfRunningNode.value = node
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
break
}
case 'node_completed': {
wfDoneCount.value += 1
wfRunningNode.value = ''
break
}
case 'workflow_completed': {
wfAdvancing.value = false
wfRunningNode.value = ''
wfResult.value = 'completed'
// 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据)
setTimeout(() => {
if (wfResult.value === 'completed') wfResult.value = null
}, 3000)
break
}
case 'workflow_failed':
case 'node_failed': {
if (evt?.type === 'workflow_failed') {
wfAdvancing.value = false
wfRunningNode.value = ''
wfResult.value = 'failed'
setTimeout(() => {
if (wfResult.value === 'failed') wfResult.value = null
}, 3000)
}
// node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态
break
}
default:
break
}
}
async function load() {
loading.value = true
errorMsg.value = ''
@@ -234,7 +376,7 @@ async function load() {
projects.value = ps
} catch (e: any) {
task.value = null
errorMsg.value = e?.toString() ?? t('taskDetail.loadFailed')
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
} finally {
loading.value = false
}
@@ -252,6 +394,8 @@ watch(taskId, () => { load() })
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
// 故此本地监听 entity=task/project 时重载当前 task。
let _unlistenDataChanged: (() => void) | null = null
// B-41: 工作流推进事件 unlistener(听 NodeStarted/NodeCompleted/WorkflowFailed 显示轻量进度)
let _unlistenWorkflowEvent: (() => void) | null = null
onMounted(async () => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
@@ -268,10 +412,18 @@ onMounted(async () => {
} catch (e) {
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
}
// B-41: 工作流推进事件监听(复用 workflowApi.onEvent, 与 workflow store 的全局监听并存无冲突 —
// 多个 listen 各自接收全量事件,本视图按 execution_id 过滤只处理自己发起的推进)
try {
_unlistenWorkflowEvent = await workflowApi.onEvent(handleWorkflowEvent)
} catch (e) {
console.error('[TaskDetail] 启动 workflow-event 监听失败:', e)
}
})
onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
})
</script>
@@ -317,6 +469,19 @@ onBeforeUnmount(() => {
gap: 8px;
}
/* B-41 工作流推进轻量进度提示 */
.wf-progress {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 6px;
font-size: 12px;
color: var(--df-text-secondary);
}
.wf-progress-count { color: var(--df-text-dim); }
.wf-progress-hint { color: var(--df-success); }
.wf-progress-hint-fail { color: var(--df-danger); }
/* F-04 review 轮次徽章 */
.review-rounds-badge {
font-size: 11px;