新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
12
src/App.vue
12
src/App.vue
@@ -100,17 +100,23 @@ onMounted(async () => {
|
||||
initTheme()
|
||||
// F-260618-23: 根 onMounted 恢复 Agentic 轮次/重试到后端(AtomicUsize 纯内存,重启回默认)。
|
||||
// GeneralPanel.vue onMounted 仅 Settings 页挂载,用户直接进 AI Chat 不同步 → 后端默认 10 与前端显示持久值不一致。
|
||||
// clamp 与 GeneralPanel.syncAgentMaxIterations/syncAgentMaxRetries 逐字对齐(双保险,IPC 幂等值相同无害)
|
||||
// clamp 与 GeneralPanel 语义对齐(0=不限透传,1-50 clamp;双保险,IPC 幂等值相同无害)
|
||||
// 包 try/catch 防 IPC 失败 reject onMounted(对齐 dataChangedListener 模式)
|
||||
try {
|
||||
const iter = appSettings.get<number>('df-ai-agent-max-iterations', 10)
|
||||
const iterClamped = Math.min(50, Math.max(1, iter || 10))
|
||||
const iterClamped = iter === 0 ? 0 : Math.min(50, Math.max(1, iter ?? 10))
|
||||
const retry = appSettings.get<number>('df-ai-agent-max-retries', 3)
|
||||
const retryClamped = Math.min(10, Math.max(0, retry ?? 3))
|
||||
await aiApi.setAgentMaxIterations(iterClamped)
|
||||
await aiApi.setAgentMaxRetries(retryClamped)
|
||||
// 设置走查-2026-06-21:并发配置同属后端 AtomicUsize 纯内存,重启回默认。原 onMounted
|
||||
// 漏同步 global/per-conv-concurrency → 用户改并发→重启→不进 Settings 直接用→后端默认 3/2
|
||||
// 前后端不一致。补齐对齐 iter/retry 恢复模式。
|
||||
const globalConv = appSettings.get<number>('df-llm-global-concurrency', 3)
|
||||
const perConvConv = appSettings.get<number>('df-llm-per-conv-concurrency', 2)
|
||||
await aiApi.setConcurrencyConfig(Math.max(1, globalConv ?? 3), Math.max(1, perConvConv ?? 2))
|
||||
} catch (e) {
|
||||
console.error('恢复 Agentic 轮次/重试配置失败:', e)
|
||||
console.error('恢复 Agentic 轮次/重试/并发配置失败:', e)
|
||||
}
|
||||
const savedLang = appSettings.get<string | null>('df-language', null)
|
||||
if (savedLang) {
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, ContentPart, ModelConfig, SkillInfo } from './types'
|
||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, ContentPart, MentionSpan, ModelConfig, SkillInfo } from './types'
|
||||
|
||||
export const aiApi = {
|
||||
/**
|
||||
* 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md。
|
||||
* F-260614-05 Phase 2c: parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。
|
||||
* parts 为空/undefined → 后端走 user() 纯文本(向后兼容)。
|
||||
* Input Augmentation: mentionSpans 透传给后端 ai_chat_send 的 mention_spans 参数
|
||||
* (后端 resolve_and_inject 按 kind 投影成 Augmentation 注入 system prompt)。
|
||||
* undefined/空数组 → 不传(后端 None 走无 mention 路径,向后兼容)。
|
||||
*/
|
||||
sendMessage(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null): Promise<string> {
|
||||
sendMessage(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
|
||||
return invoke('ai_chat_send', {
|
||||
message,
|
||||
language: language || 'zh-CN',
|
||||
@@ -19,11 +22,13 @@ export const aiApi = {
|
||||
parts: parts && parts.length > 0 ? parts : null,
|
||||
// F-260616-09 B 批4(决策 e):传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
|
||||
conversationId: conversationId || null,
|
||||
// Input Augmentation: 非空数组才传(undefined/空让 payload 不含该键,后端默认 None)。
|
||||
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
|
||||
})
|
||||
},
|
||||
|
||||
/** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程);parts 透传同 sendMessage。 */
|
||||
forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null): Promise<string> {
|
||||
/** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
|
||||
forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
|
||||
return invoke('ai_chat_force_send', {
|
||||
message,
|
||||
language: language || 'zh-CN',
|
||||
@@ -32,6 +37,8 @@ export const aiApi = {
|
||||
parts: parts && parts.length > 0 ? parts : null,
|
||||
// F-260616-09 B 批4(决策 e):传 conv_id,强制复位+发送仅作用于目标 conv。
|
||||
conversationId: conversationId || null,
|
||||
// Input Augmentation: 同 sendMessage,非空数组才传(后端 mention_spans 参数)。
|
||||
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -94,8 +101,9 @@ export const aiApi = {
|
||||
return invoke('ai_stop_loop', { conversationId })
|
||||
},
|
||||
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用) */
|
||||
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null }[]> {
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||||
* 阶段4:返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||||
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||||
return invoke('ai_pending_tool_calls', { convId })
|
||||
},
|
||||
|
||||
@@ -224,6 +232,17 @@ export const aiApi = {
|
||||
return invoke('ai_list_skills')
|
||||
},
|
||||
|
||||
/**
|
||||
* Input Augmentation:进程内重载 skills 缓存(OnceLock → RwLock 后支持热重载)。
|
||||
*
|
||||
* 用户增删/修改 SKILL.md 后无需重启即可让后续 `/技能` 注入读到最新内容。
|
||||
* 后端 ai_reload_skills 复用 scan_skills 走剥 frontmatter 的读取 + invalidate 缓存,
|
||||
* 返回重载后的全量 skills 列表(供前端同步刷新 skills state)。
|
||||
*/
|
||||
reloadSkills(): Promise<SkillInfo[]> {
|
||||
return invoke('ai_reload_skills')
|
||||
},
|
||||
|
||||
/** F-260619-03 Phase A: 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
||||
getAllowedDirs(): Promise<string[]> {
|
||||
return invoke<string[]>('ai_get_allowed_dirs')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { IdeaRecord, CreateIdeaInput, PromotionResult } from './types'
|
||||
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord } from './types'
|
||||
|
||||
export const ideaApi = {
|
||||
/** 列出灵感,可选按 status 过滤(draft/pending_review/promoted 等) */
|
||||
@@ -28,4 +28,9 @@ export const ideaApi = {
|
||||
promote(id: string): Promise<PromotionResult> {
|
||||
return invoke('promote_idea', { id })
|
||||
},
|
||||
|
||||
/** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */
|
||||
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
|
||||
return invoke('list_idea_evaluations', { ideaId })
|
||||
},
|
||||
}
|
||||
|
||||
124
src/api/types.ts
124
src/api/types.ts
@@ -20,6 +20,8 @@ export interface IdeaRecord {
|
||||
promoted_to: string | null
|
||||
ai_analysis: string | null
|
||||
scores: string | null
|
||||
/** 关联灵感 id JSON 数组字符串(对齐后端 related_ids Option<String>),null/空串=无关联 */
|
||||
related_ids: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -32,6 +34,22 @@ export interface CreateIdeaInput {
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵感评估历史记录(后端 list_idea_evaluations 返回)。
|
||||
* 每次评估(启发式/LLM/降级)落一行,version 递增,按 version DESC 返回。
|
||||
* ai_analysis / scores 为 JSON 字符串(对齐 IdeaRecord 同名字段)。
|
||||
*/
|
||||
export interface IdeaEvaluationRecord {
|
||||
id: string
|
||||
idea_id: string
|
||||
version: number
|
||||
ai_analysis: string | null
|
||||
scores: string | null
|
||||
score: number | null
|
||||
evaluated_by: string | null
|
||||
evaluated_at: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目
|
||||
// ============================================================
|
||||
@@ -281,7 +299,14 @@ export type AiChatEvent = ({
|
||||
// 不写消息/不动 pending(Started/Completed 仍独立发),仅提示性事件。
|
||||
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
|
||||
} | {
|
||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
||||
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
||||
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
||||
// 开关 df-ai-unified-approval:true=归一进 pendingApprovals 带 kind;false=回退 DirAuthDialog 老链路。
|
||||
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string; diff?: string
|
||||
kind?: 'path' | 'risk'
|
||||
} | {
|
||||
type: 'AiApprovalResult'; id: string; approved: boolean
|
||||
} | {
|
||||
@@ -343,9 +368,86 @@ export interface AiMessage {
|
||||
* 历史消息从 DB JSON 反序列化时透传(useAiConversations switchConversation)。
|
||||
*/
|
||||
parts?: ContentPart[]
|
||||
/**
|
||||
* Input Augmentation:@ mention 区间元数据(仅 user 消息)。
|
||||
*
|
||||
* 用户选中 @项目/@任务/@灵感/`/技能` 时按选区记 {start,length,kind,refId,label},
|
||||
* 随消息透传到后端 ai_chat_send 的 mention_spans 参数(后端 resolve 投影成 Augmentation 注入)。
|
||||
* 前端 MessageList 据此精确切区间渲染 chip(替代正则,防误匹配)。
|
||||
*
|
||||
* undefined/空=无 mention(纯文本消息零回归);历史消息从 DB 反序列化时透传。
|
||||
*/
|
||||
mentionSpans?: MentionSpan[]
|
||||
/**
|
||||
* Input Augmentation:resolve 后的增强上下文(仅展示用,后端 resolve 的回传或落库快照)。
|
||||
*
|
||||
* serde 镜像后端 df-types Augmentation(tag=kind, snake_case 变体名)。
|
||||
* 当前透传链路仅传 mentionSpans,augmentations 主要用于落库消息回显/调试;前端宽容对待
|
||||
* (不强制校验变体字段,未知 kind 忽略,对齐后端「新增变体向后兼容」设计)。
|
||||
*/
|
||||
augmentations?: Augmentation[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Input Augmentation 层类型(@ mention / / skill 统一增强)
|
||||
// ============================================================
|
||||
//
|
||||
// 与后端 crates/df-types/src/augmentation.rs 严格对齐:
|
||||
// - MentionSpanDto(start/length/kind/refId/label)→ 前端 MentionSpan
|
||||
// - Augmentation(serde tag=kind, snake_case)→ 前端 Augmentation 宽松镜像
|
||||
// - SanitizedPath(String newtype, serde transparent = 裸字符串)→ 前端 type SanitizedPath = string
|
||||
//
|
||||
// refId 用 camelCase(后端 serde rename = "refId",与前端 TS 风格一致);
|
||||
// Augmentation 内部字段沿用后端 snake_case(path/project_name 等,与 wire 严格对齐,不做转译)。
|
||||
|
||||
/**
|
||||
* 用户消息内 mention 区间的元数据(对齐后端 MentionSpanDto)。
|
||||
*
|
||||
* 前端按 {start, length} 在原文中切出区间并替换为 chip;区间被编辑破坏(长度/内容不再匹配)
|
||||
* 时降级为纯文本展示,避免正则误匹配。
|
||||
*
|
||||
* 注意字段名:refId(camelCase,后端 serde rename)。
|
||||
*/
|
||||
export interface MentionSpan {
|
||||
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
|
||||
start: number
|
||||
/** 区间长度 */
|
||||
length: number
|
||||
/** kind 标签:project / task / idea / skill */
|
||||
kind: 'project' | 'task' | 'idea' | 'skill'
|
||||
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
|
||||
refId: string
|
||||
/** chip 展示文本(项目名/任务标题/技能名) */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏后的文件系统路径(对齐后端 SanitizedPath newtype)。
|
||||
*
|
||||
* 后端 serde(transparent)在 wire 上即裸字符串,前端用 string 别名即可。
|
||||
* Local provider(localhost/127.0.0.1/...)保留全路径;Remote 仅 basename。
|
||||
*/
|
||||
export type SanitizedPath = string
|
||||
|
||||
/**
|
||||
* Augmentation 变体集合字面量(对齐后端 serde tag=kind, rename_all=snake_case)。
|
||||
*/
|
||||
export type AugmentationKind = 'project' | 'task' | 'idea' | 'skill'
|
||||
|
||||
/**
|
||||
* resolve 后的增强上下文(对齐后端 Augmentation)。
|
||||
*
|
||||
* 宽松镜像:tag=kind 区分变体,各变体字段按需声明(后端新增字段时前端不必同步改,
|
||||
* 多余字段 JSON 解析保留不报错)。前端主要读 name/title/path 用于 chip/展示,
|
||||
* 需读具体变体字段时用 type narrowing(kind === 'project' 等)。
|
||||
*/
|
||||
export type Augmentation = {
|
||||
kind: AugmentationKind
|
||||
/** 变体特有字段按需读取(用 type narrowing 访问);索引签名容纳后端新增字段 */
|
||||
[field: string]: unknown
|
||||
}
|
||||
|
||||
// AiToolCallInfo.status union 类型 — 前端构建(后端无对应 struct,经 audit log/stream event 组装)。
|
||||
// 值集核验:grep src/composables/ai + src/components 实际使用点 = {running,pending_approval,completed,rejected}。
|
||||
// 前端为权威源(无后端 enum 约束),保持现状仅抽 alias 便于复用。
|
||||
@@ -363,6 +465,25 @@ export interface AiToolCallInfo {
|
||||
/** AE-2025-03:write_file 行级 unified diff(旧文件 vs 新内容),审批卡即时预览。
|
||||
* 旧文件不存在(新建)或非 write_file 为 undefined,前端回退显新 content。 */
|
||||
diff?: string
|
||||
/**
|
||||
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 三选项,
|
||||
* 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。
|
||||
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
||||
* 调 aiApi.approve(一次性)。
|
||||
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
||||
* 开关 df-ai-unified-approval 控制 path 类是否归一进 pendingApprovals 带此字段。
|
||||
*/
|
||||
kind?: 'path' | 'risk'
|
||||
/**
|
||||
* path_auth 审批链阶段3b:'path' 类挂起携带的目录信息(规范化后的待授权目录)。
|
||||
* AiDirAuthRequired 归一为 pendingApprovals 项时透传,ToolCard 显提示"LLM 想访问 X 目录"。
|
||||
* 'risk' 类为 undefined。
|
||||
*/
|
||||
dir?: string
|
||||
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
|
||||
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** 对话列表摘要 */
|
||||
@@ -494,9 +615,8 @@ export interface UpdateKnowledgeInput {
|
||||
/** 知识库行为配置(提取 + 注入 + 向量检索) */
|
||||
export interface KnowledgeConfig {
|
||||
auto_extract: boolean
|
||||
trigger_mode: 'on_complete' | 'on_idle' | 'manual_only'
|
||||
trigger_mode: 'on_complete' | 'manual_only'
|
||||
min_messages: number
|
||||
idle_timeout_ms: number
|
||||
auto_inject: boolean
|
||||
/** 语义检索(向量)开关,默认 false——关闭时纯 LIKE 零外部依赖 */
|
||||
vector_enabled: boolean
|
||||
|
||||
@@ -57,8 +57,11 @@
|
||||
</div>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
|
||||
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny) -->
|
||||
<DirAuthDialog @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
|
||||
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
|
||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
||||
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发) -->
|
||||
<div v-if="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||
<div class="ai-queue-head">
|
||||
@@ -126,6 +129,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { useAiStore } from '../stores/ai'
|
||||
import { useAppSettingsStore } from '../stores/appSettings'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
||||
import ChatInput from './ai/ChatInput.vue'
|
||||
@@ -145,6 +149,11 @@ const props = defineProps<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
|
||||
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
|
||||
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
|
||||
const appSettings = useAppSettingsStore()
|
||||
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
|
||||
|
||||
// ── UX-2025-20: 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
|
||||
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
|
||||
|
||||
@@ -44,7 +44,33 @@
|
||||
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
</div>
|
||||
<div class="ai-tool-actions">
|
||||
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
|
||||
- kind='path'(路径授权挂起):once/always/deny 三选项,调 authorizeDir。
|
||||
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项,调 approve。 -->
|
||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('once')">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--always"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('always')">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthAlways') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('deny')">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiChat.dirAuthDeny') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove(true)">
|
||||
@@ -127,8 +153,12 @@ const emit = defineEmits<{
|
||||
toggle: [id: string]
|
||||
/** read_file "展开全部/收起" → 内容级展开 */
|
||||
'expand-content': [id: string]
|
||||
/** 审批按钮 → 父级转发 store.approveToolCall */
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
/**
|
||||
* 审批按钮 → 父级转发 store.approveToolCall。
|
||||
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'always'|'deny')。
|
||||
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
|
||||
*/
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
||||
}>()
|
||||
|
||||
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
|
||||
@@ -189,6 +219,27 @@ async function onApprove(approved: boolean) {
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
|
||||
/**
|
||||
* path_auth 审批链阶段3b:path 类(路径授权挂起)三选项处理。
|
||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
||||
* 与 onApprove 同款 loading + 超时兜底,但 emit 带 decision(approved=false 仅占位,
|
||||
* 实际语义由 decision 决定;调用方 store.approveToolCall 按 decision 走 authorizeDir)。
|
||||
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,once/always/deny 三选项
|
||||
* 本身就是用户明示决策,二次确认反增摩擦)。
|
||||
*/
|
||||
async function onAuthorize(decision: 'once' | 'always' | 'deny') {
|
||||
if (approving.value) return // 防重入
|
||||
approving.value = true
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
||||
showApproveTimeoutToast()
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
// approved=false 仅占位:store.approveToolCall 据 decision 走 authorizeDir,approved 入参被忽略。
|
||||
emit('approve', { id: props.tc.id, approved: false, decision })
|
||||
}
|
||||
|
||||
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。原 cmdOutputExpanded/
|
||||
// httpBodyExpanded 初始化逻辑已随结果区迁入 ToolResultBody,此处仅留 approving 复位。
|
||||
watch(() => props.tc.status, (s) => {
|
||||
@@ -350,6 +401,9 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
|
||||
}
|
||||
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
|
||||
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
|
||||
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
|
||||
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
|
||||
/* 审批 loading(B-260616-08):禁用两按钮防重复点击,spinner 转在原位 */
|
||||
|
||||
@@ -60,8 +60,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 审批按钮 → 父组件转发 store.approveToolCall */
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
/**
|
||||
* 审批按钮 → 父组件转发 store.approveToolCall。
|
||||
* path_auth 审批链阶段3b:path 类带 decision('once'|'always'|'deny'),risk 类仅 approved boolean。
|
||||
*/
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
||||
/** 批量审批 → 父组件调用 approveAll/rejectAll */
|
||||
'batch-approve': [decision: 'approve' | 'reject']
|
||||
}>()
|
||||
@@ -192,6 +195,7 @@ const TOOL_GROUP_I18N_KEY: Record<string, string> = {
|
||||
read_file: 'readFile',
|
||||
write_file: 'writeFile',
|
||||
list_directory: 'listDirectory',
|
||||
grep: 'grep',
|
||||
create_task: 'createTask',
|
||||
create_project: 'createProject',
|
||||
create_idea: 'createIdea',
|
||||
|
||||
@@ -57,6 +57,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grep 结果:跨文件内容搜索(content/files_with_matches/count 三模式,F-260621)
|
||||
对齐 search_files 文件栏样式;content 模式按命中行渲染(文件:行:内容),支持 -C 上下文折叠 -->
|
||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
||||
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
|
||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
|
||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
||||
</div>
|
||||
<!-- files_with_matches 模式:仅文件列表 -->
|
||||
<div v-if="parsed?.output_mode === 'files_with_matches'" class="ai-tool-dir-entries">
|
||||
<div v-for="(f, i) in parsed?.files" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ f }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- count 模式:文件 + 命中行数 -->
|
||||
<div v-else-if="parsed?.output_mode === 'count'" class="ai-tool-dir-entries">
|
||||
<div v-for="(c, i) in parsed?.counts" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ c.file }}</span>
|
||||
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- content 模式:命中行(文件:行:内容),支持上下文折叠 -->
|
||||
<div v-else class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-head">
|
||||
<span class="ai-tool-grep-file">{{ m.file }}</span>
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">:{{ m.line }}</span>
|
||||
</div>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- write_file 结果 -->
|
||||
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
@@ -142,8 +180,10 @@ import {
|
||||
formatSizeDiff,
|
||||
combineOutputs,
|
||||
formatToolResult,
|
||||
formatGrep,
|
||||
dirIcon,
|
||||
fileIcon,
|
||||
searchIcon,
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
@@ -201,6 +241,16 @@ const cmdOutput = computed(() => {
|
||||
*/
|
||||
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120))
|
||||
|
||||
/**
|
||||
* grep 命中计数标签(F-260621):G-1 复用 useToolCard.formatGrep 单一函数,
|
||||
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)。
|
||||
* parsed 为 null 返空串,等价原逻辑;非 null 委托 formatGrep。
|
||||
*/
|
||||
const grepHitLabel = computed(() => {
|
||||
const r = parsed.value
|
||||
return r ? formatGrep(r) : ''
|
||||
})
|
||||
|
||||
// SW-260618-06: status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
|
||||
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
|
||||
watch(() => props.tc.status, (s) => {
|
||||
@@ -266,6 +316,71 @@ watch(() => props.tc.status, (s) => {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-file-meta--warn {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
|
||||
/* grep 结果(F-260621):文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
||||
.ai-tool-grep-result {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-grep-matches {
|
||||
padding: 4px 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-tool-grep-match {
|
||||
padding: 3px 12px;
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
.ai-tool-grep-match:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ai-tool-grep-match-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.ai-tool-grep-file {
|
||||
color: var(--df-accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-grep-line {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-grep-content {
|
||||
display: block;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: rgba(61,219,160,0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-xs, 3px);
|
||||
}
|
||||
.ai-tool-grep-context {
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: var(--df-radius-xs, 3px);
|
||||
}
|
||||
.ai-tool-grep-context code {
|
||||
display: block;
|
||||
padding: 4px 6px;
|
||||
white-space: pre;
|
||||
}
|
||||
.ai-tool-expand-btn {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
|
||||
@@ -119,7 +119,7 @@ import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord } from '../../api/types'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
|
||||
@@ -252,17 +252,13 @@ const filteredSkills = computed(() => {
|
||||
})
|
||||
|
||||
watch(inputText, (v) => {
|
||||
if (pendingSkill.value) {
|
||||
// 已选技能 chip 态不开任何联想浮层
|
||||
skillOpen.value = false
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
return
|
||||
}
|
||||
// Input Augmentation: skill chip + @ 标记共存(两者正交 user intent)——删原 pendingSkill
|
||||
// 非空时强制关 mention 的分支。skill chip 是独立 UI 态(已选技能),@ 是输入态(光标触发),
|
||||
// 用户可同时持有已选技能并在文本里 @ 引用实体。
|
||||
// / 技能联想(仅当输入以 / 开头且光标在首字符区)
|
||||
skillOpen.value = v.startsWith('/')
|
||||
if (skillOpen.value) skillIndex.value = 0
|
||||
// UX-10: @ 实体引用(技能联想与 @ 联想互斥,二者同时仅一开)
|
||||
// UX-10: @ 实体引用(技能联想与 @ 联想互斥,二者同时仅一开;视觉打架防护)
|
||||
if (skillOpen.value) {
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
@@ -307,6 +303,8 @@ function detectMentionTrigger() {
|
||||
function selectSkill(s: SkillInfo) {
|
||||
pendingSkill.value = s
|
||||
inputText.value = ''
|
||||
// selectSkill 清空 inputText,pendingMentionSpans 指向的区间随之失效,一并清空防孤儿 span
|
||||
pendingMentionSpans.value = []
|
||||
skillOpen.value = false
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
@@ -338,6 +336,10 @@ const mentionOpen = ref(false)
|
||||
const mentionIndex = ref(0)
|
||||
// @ 触发符在 inputText 中的起始下标(用于选中后精确替换 @query 段)
|
||||
const mentionStart = ref(-1)
|
||||
// Input Augmentation: pending mention 区间元数据(selectMention 记录,handleSend 透传后清空)。
|
||||
// 每个 span 描述用户气泡内一段 [类型: 名] 文本在 inputText 中的 {start,length} + kind/refId/label,
|
||||
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
|
||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||
|
||||
const mentionGroupLabel = computed(() => ({
|
||||
project: t('aiChat.mentionGroupProject'),
|
||||
@@ -424,6 +426,16 @@ function selectMention(item: MentionItem) {
|
||||
? `[${t('aiChat.mentionGroupTask')}: ${item.name}]`
|
||||
: `[${t('aiChat.mentionGroupIdea')}: ${item.name}]`
|
||||
inputText.value = before + label + ' ' + after
|
||||
// Input Augmentation: 记 pending mention 区间(start=before.length,label 区间长度=label.length)。
|
||||
// before 已包含此前所有插入的 [类型: 名] 标记,故 before.length 恰为本 label 在最终文本中的起点。
|
||||
// refId 用实体 id(后端 resolve 用),label 即 chip 展示文本(== 后段校验用 == 文本)。
|
||||
pendingMentionSpans.value.push({
|
||||
start: before.length,
|
||||
length: label.length,
|
||||
kind: item.type,
|
||||
refId: item.id,
|
||||
label,
|
||||
})
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
mentionIndex.value = 0
|
||||
@@ -548,6 +560,15 @@ async function handleSend() {
|
||||
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
|
||||
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
|
||||
const snapshotImgs = pendingImages.value.slice()
|
||||
// Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。
|
||||
// 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim()
|
||||
// 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。
|
||||
// 此处按剥掉的前导空白长度整体左移 start(尾部 trim 不影响已记录的 chip start;chip 不在尾部空白)。
|
||||
const rawLeadingWs = inputText.value.length - inputText.value.replace(/^\s+/, '').length
|
||||
const snapshotSpans = pendingMentionSpans.value.slice().map(sp => ({
|
||||
...sp,
|
||||
start: Math.max(0, sp.start - rawLeadingWs),
|
||||
}))
|
||||
const parts: ContentPart[] | undefined = snapshotImgs.length > 0
|
||||
? [
|
||||
// 文本片(非空时作首片,便于后端 2c 接入后 content 与 parts 文本一致;
|
||||
@@ -573,6 +594,8 @@ async function handleSend() {
|
||||
inputText.value = ''
|
||||
pendingSkill.value = null
|
||||
pendingImages.value = []
|
||||
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
|
||||
pendingMentionSpans.value = []
|
||||
skillOpen.value = false
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
@@ -580,7 +603,9 @@ async function handleSend() {
|
||||
// 同步清空输入框后立即发送:sendMessage 内部同步 push 用户消息,
|
||||
// 清空与 push 合并到同一渲染周期 flush,消除"输入框已空但消息未显示"的间隙
|
||||
try {
|
||||
await store.sendMessage(text, skill?.name, false, parts)
|
||||
// Input Augmentation: 透传 mentionSpans(L0 直接 doSend 注入 + 本地 user 消息挂 mentionSpans 渲染 chip;
|
||||
// L1 入队续发当前不挂 spans,见 useAiSend.sendMessage 设计取舍注释)
|
||||
await store.sendMessage(text, skill?.name, false, parts, snapshotSpans.length > 0 ? snapshotSpans : undefined)
|
||||
} catch (e) {
|
||||
console.error('[AI] 发送失败:', e)
|
||||
inputText.value = text
|
||||
@@ -589,6 +614,10 @@ async function handleSend() {
|
||||
if (parts) {
|
||||
pendingImages.value = snapshotImgs
|
||||
}
|
||||
// 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐)
|
||||
if (snapshotSpans.length) {
|
||||
pendingMentionSpans.value = snapshotSpans
|
||||
}
|
||||
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
|
||||
@@ -625,6 +654,7 @@ function clearInput(): void {
|
||||
inputText.value = ''
|
||||
pendingSkill.value = null
|
||||
pendingImages.value = []
|
||||
pendingMentionSpans.value = []
|
||||
skillOpen.value = false
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { formatRelativeZh } from '../../utils/time'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
import type { AiConversationSummary } from '../../api/types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -329,7 +329,7 @@ function onSelectSearchResult(id: string) {
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
return formatRelativeZh(ts)
|
||||
return formatRelative(ts)
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
<template>
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
条件:pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
|
||||
<!-- F-260619-03 Phase B + path_auth 审批链阶段1: 路径授权弹窗(LLM 想访问白名单外目录,
|
||||
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
||||
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
||||
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
每项独立三按钮:点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
|
||||
点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuth 模块级 ref(useAiEvents)直接导入。
|
||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-if="showDirAuthCard" class="ai-dir-auth">
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" class="ai-dir-auth">
|
||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||
<span class="ai-dir-auth-hint">
|
||||
{{ $t('aiChat.dirAuthHint', { tool: pendingDirAuth?.tool, path: pendingDirAuth?.path }) }}
|
||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
||||
</span>
|
||||
<div class="ai-dir-auth-actions">
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--once"
|
||||
:disabled="dirAuthActing"
|
||||
@click="handleAuthorize('once')"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'once')"
|
||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
||||
:disabled="dirAuthActing"
|
||||
@click="handleAuthorize('always')"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'always')"
|
||||
>{{ $t('aiChat.dirAuthAlways') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
|
||||
:disabled="dirAuthActing"
|
||||
@click="handleAuthorize('deny')"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'deny')"
|
||||
>{{ $t('aiChat.dirAuthDeny') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingDirAuth } from '../../composables/ai/useAiEvents'
|
||||
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
|
||||
import { aiApi } from '../../api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -45,37 +47,57 @@ const emit = defineEmits<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前视图是否正在生成(切走后台生成时光标不显示,与 MaxRoundsCard 一致守卫)
|
||||
// F-260620 根治(授权挂起无声卡死):挂起态弹窗显示只依赖 isGenerating(conv),不依赖 streaming。
|
||||
// streaming 会被流式看门狗超时清(useAiStream onStreamTimeout),而 AiDirAuthRequired 挂起常发生在
|
||||
// 首轮慢响应(看门狗已超时清 streaming)之后——此时 streaming=false 但后端 generating=true、
|
||||
// generatingConvs 含 conv(useAiEvents handleEvent 对非完成/错误事件 add)。若守卫仍 && streaming,
|
||||
// 挂起弹窗永不显示 → loop 无声卡死(用户发消息无响应)。去 streaming,isGenerating(conv) 单条件覆盖。
|
||||
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.state.streaming && store.isGenerating(store.state.activeConversationId),
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
const dirAuthActing = ref(false)
|
||||
const showDirAuthCard = computed(() =>
|
||||
pendingDirAuth.value !== null && isViewingGenerating.value,
|
||||
)
|
||||
// pendingDirAuths 非空 + 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// 阶段1:数组化后,visibleDirAuths 是 pendingDirAuths 的派生视图(按 conv 过滤当前会话挂起项),
|
||||
// 与 showDirAuthCard 合并为单一 computed(非空即显)。
|
||||
const visibleDirAuths = computed(() => {
|
||||
if (!isViewingGenerating.value) return []
|
||||
// 仅显当前会话的挂起项(active conv);其他会话的挂起不在本视图弹窗。
|
||||
const active = store.state.activeConversationId
|
||||
return pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === active)
|
||||
})
|
||||
|
||||
/** 点三选项之一:调 ai_authorize_dir。后端 remove pending → 写授权/拒 → execute → try_continue。
|
||||
* 不主动清 pendingDirAuth——由后端 AiApprovalResult/AiCompleted/AiError 在 useAiEvents 内清。
|
||||
* IPC 失败回滚 acting 让用户可重试。 */
|
||||
async function handleAuthorize(decision: 'once' | 'always' | 'deny'): Promise<void> {
|
||||
const id = pendingDirAuth.value?.id
|
||||
if (!id) return
|
||||
dirAuthActing.value = true
|
||||
// 阶段1:逐 id 追踪"处理中"态(数组多挂起并存,单 boolean 不够)。
|
||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
||||
const actingIds = reactive(new Set<string>())
|
||||
|
||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> {
|
||||
if (actingIds.has(id)) return
|
||||
actingIds.add(id)
|
||||
try {
|
||||
await aiApi.authorizeDir(id, decision)
|
||||
} catch (e) {
|
||||
dirAuthActing.value = false
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
|
||||
actingIds.delete(id) // IPC 失败:复位该 id,允许重试
|
||||
return
|
||||
}
|
||||
// F-260620 根治(授权按钮永转圈):IPC 成功后 try_continue spawn 续跑 loop,续跑 loop 可能立刻又
|
||||
// 触发 AiDirAuthRequired(同 id 不可能,但其他 id 可能)置新 pendingDirAuths 项;该 id 的项会由
|
||||
// AiApprovalResult 按 id filter 移除。这里不依赖 pendingDirAuths 状态复位 actingIds——改为
|
||||
// watch pendingDirAuths,某 id 离开数组(已被后端消费)即删 actingIds,精准复位。
|
||||
}
|
||||
|
||||
/** pendingDirAuth 离开挂起态(后端事件已清)时复位 acting,允许下次再操作 */
|
||||
watch(() => pendingDirAuth.value, (v) => {
|
||||
if (!v) dirAuthActing.value = false
|
||||
/** pendingDirAuths 变化时,离开数组(已被后端 AiApprovalResult/AiCompleted/AiError 按 id 移除)的
|
||||
* id 从 actingIds 删,精准复位"处理中"态(数组化后多挂起,单 watch===null 模式不再适用)。 */
|
||||
watch(() => pendingDirAuths.value, (items) => {
|
||||
const liveIds = new Set(items.map(p => p.id))
|
||||
for (const id of [...actingIds]) {
|
||||
if (!liveIds.has(id)) actingIds.delete(id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -37,18 +37,26 @@ const emit = defineEmits<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前视图是否正在生成(切走后台生成时光标不显示)
|
||||
// F-260620 根治(达 max 挂起无声卡死):同 DirAuthDialog,去 streaming 依赖。
|
||||
// 达 max(agentic:1186 guard.disarm + AiMaxRoundsReached,generating 保持 true)发生在多轮迭代后,
|
||||
// 耗时长,130s 流式看门狗极易在迭代间隙超时清 streaming → 卡片因 streaming=false 不弹 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。达 max 比 DirAuthAuth 更易触发(必经多轮)。去 streaming,
|
||||
// isGenerating(conv) 单条件覆盖挂起态(达 max 时 generatingConvs 含 conv,useAiEvents handleEvent 对非完成/错误事件 add)。
|
||||
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.state.streaming && store.isGenerating(store.state.activeConversationId),
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
||||
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
||||
// maxRoundsActing 本地 ref 持按钮 loading:点继续/停止后禁双按钮,等后端事件
|
||||
// (continueLoop→新一轮 AiAgentRound;stopLoop→AiCompleted)自然清卡片。
|
||||
const maxRoundsActing = ref(false)
|
||||
const showMaxRoundsCard = computed(() =>
|
||||
pendingMaxRounds.value && isViewingGenerating.value,
|
||||
!!pendingMaxRounds.value
|
||||
&& pendingMaxRounds.value === store.state.activeConversationId
|
||||
&& isViewingGenerating.value,
|
||||
)
|
||||
|
||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||
@@ -82,7 +90,7 @@ async function handleStopLoop(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** pendingMaxRounds 离开暂停态(后端事件已清)时复位 acting,允许下次再操作 */
|
||||
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
|
||||
watch(() => pendingMaxRounds.value, (v) => {
|
||||
if (!v) maxRoundsActing.value = false
|
||||
})
|
||||
|
||||
@@ -18,9 +18,10 @@ import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useMarkdown } from '../../composables/useMarkdown'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import { formatRelativeZh, formatDate } from '../../utils/time'
|
||||
import type { AiMessage, ContentPart } from '../../api/types'
|
||||
import { formatRelative, formatDate } from '../../utils/time'
|
||||
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
||||
|
||||
defineProps<{
|
||||
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
|
||||
@@ -50,6 +51,14 @@ const {
|
||||
getMarked,
|
||||
getPurify,
|
||||
} = useMarkdown()
|
||||
|
||||
// ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══
|
||||
// appSettings key `df-ai-streaming-md`,默认 true(开):流式走块级 memo + rAF 节流的
|
||||
// Markdown 分块渲染(代码块/列表/标题流式过程中就有格式)。关(false):回退纯文本短路
|
||||
// (escapeFallback,每 delta 全文转义,<br> 换行)——即 AR-1 修复前的原行为,供性能敏感/
|
||||
// 极端掉帧场景降级。读走 appSettings 缓存(响应式,Settings 改动即时生效)。
|
||||
const appSettings = useAppSettingsStore()
|
||||
const streamingMdEnabled = computed(() => appSettings.get<boolean>('df-ai-streaming-md', true))
|
||||
// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
|
||||
// 流式 parse 经 getter 取最新引用,行为零变化。
|
||||
// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
|
||||
@@ -130,7 +139,9 @@ interface StreamBlock {
|
||||
}
|
||||
function renderStreamingBlocks(text: string): StreamBlock[] {
|
||||
if (!text) return []
|
||||
if (!mdReady.value || !getMarked() || !getPurify()) {
|
||||
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
|
||||
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
|
||||
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
|
||||
return [{ html: escapeFallback(text), key: 'fallback' }]
|
||||
}
|
||||
const blocks = splitBlocks(text)
|
||||
@@ -138,13 +149,34 @@ function renderStreamingBlocks(text: string): StreamBlock[] {
|
||||
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
|
||||
return blocks.map((b, i) => {
|
||||
const isTail = i === n - 1
|
||||
const html = isTail ? parseBlockNoCache(b) : parseBlock(b)
|
||||
// AR-1 代码块降级:末块若是未闭合的代码围栏(```/~~~ 数为奇数,说明代码块还没结束),
|
||||
// 不走 marked.parse(未闭合围栏 → marked 推断为代码块 + hljs 对不完整代码高亮,
|
||||
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
|
||||
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
|
||||
const degrade = isTail && isUnclosedCodeFence(b)
|
||||
const html = degrade
|
||||
? escapeFallback(b)
|
||||
: isTail ? parseBlockNoCache(b) : parseBlock(b)
|
||||
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
|
||||
const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}`
|
||||
return { html, key }
|
||||
})
|
||||
}
|
||||
|
||||
/// 末块未闭合代码围栏检测:统计行首(可选 ≤3 空格)的 ``` / ~~~ 围栏开/闭数量,
|
||||
/// 奇数 = 有未闭合的代码块(流式还在写入该代码块)。仅末块调用,前块必已闭合(splitBlocks
|
||||
/// 已把完整 code token 切成独立块)。对齐 marked 围栏规则(行首 ≤3 空格 + 3+ 反引号/波浪)。
|
||||
function isUnclosedCodeFence(block: string): boolean {
|
||||
let fenceCount = 0
|
||||
const lines = block.split('\n')
|
||||
for (const line of lines) {
|
||||
// 行首 ≤3 空格 + 3+ 反引号或波浪(marked 围栏开关判定)
|
||||
const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line)
|
||||
if (m) fenceCount++
|
||||
}
|
||||
return fenceCount % 2 === 1
|
||||
}
|
||||
|
||||
/// 轻量字符串哈希:用于 block key 稳定性(非加密,仅避免长文本做 key)
|
||||
function simpleHash(s: string): number {
|
||||
let h = 5381
|
||||
@@ -178,6 +210,62 @@ function renderContent(msg: AiMessage): string {
|
||||
return renderMd(msg.content)
|
||||
}
|
||||
|
||||
// ── Input Augmentation: 用户消息按 mention 区间切段(弃正则,纯元数据驱动) ──
|
||||
// 解决 🔴HIGH-2:正则误匹配(用户手打 [项目: x] 格式 / AI 模仿格式 / 项目名含 ] / 嵌套)。
|
||||
// 按 msg.mentionSpans(无则纯文本一段)按 start 升序切段:text → chip 替换。
|
||||
// 每 span 安全校验:content.slice(start, start+length) === span.label 否则降级为 text
|
||||
// (用户编辑破坏区间,如改了项目名/删了字符致偏移失效)。重叠/越界 span 也降级。
|
||||
type UserContentSegment =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'chip'; span: MentionSpan }
|
||||
|
||||
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
|
||||
const content = msg.content
|
||||
const spans = msg.mentionSpans
|
||||
// 无 mentionSpans(历史消息/纯文本)→ 一段 text 零回归
|
||||
if (!spans || spans.length === 0) {
|
||||
return [{ type: 'text', text: content }]
|
||||
}
|
||||
|
||||
// 按 start 升序排序(稳定;不影响原数组)
|
||||
const sorted = [...spans].sort((a, b) => a.start - b.start)
|
||||
|
||||
const segments: UserContentSegment[] = []
|
||||
let cursor = 0 // 已消费到的字符偏移
|
||||
|
||||
for (const span of sorted) {
|
||||
const start = span.start
|
||||
const end = start + span.length
|
||||
|
||||
// 越界:起点/终点超出 content 长度 → 降级跳过(区间已失效)
|
||||
if (start < 0 || end > content.length) continue
|
||||
// 与前段重叠:起点在已消费 cursor 之前 → 降级跳过(防区间相互覆盖产生错乱)
|
||||
if (start < cursor) continue
|
||||
// 安全校验:切片内容必须 === span.label(用户编辑破坏区间则不等 → 降级跳过该 span)
|
||||
if (content.slice(start, end) !== span.label) continue
|
||||
|
||||
// 前导 text(若有)
|
||||
if (start > cursor) {
|
||||
segments.push({ type: 'text', text: content.slice(cursor, start) })
|
||||
}
|
||||
// chip 段
|
||||
segments.push({ type: 'chip', span })
|
||||
cursor = end
|
||||
}
|
||||
|
||||
// 收尾 tail text(末个 span 之后剩余)
|
||||
if (cursor < content.length) {
|
||||
segments.push({ type: 'text', text: content.slice(cursor) })
|
||||
}
|
||||
|
||||
// 极端:所有 span 全降级且 content 非空 → segments 可能为空(无前导/tail),补一段完整 text
|
||||
if (segments.length === 0 && content.length > 0) {
|
||||
return [{ type: 'text', text: content }]
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
// ── UX-2025-20: 空状态示例问题 ──
|
||||
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
|
||||
const examplePrompts = [
|
||||
@@ -866,7 +954,16 @@ defineExpose({
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
||||
{{ item.msg.content }}
|
||||
<!-- Input Augmentation: 按 mention 区间切段渲染(chip 精确替换 [类型: 名] 标记,弃正则)。
|
||||
无 mentionSpans 时 segmentUserContent 返回单段 text(零回归,等同原 {{ content }})。 -->
|
||||
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i">
|
||||
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="ai-msg-chip"
|
||||
:class="'ai-msg-chip--' + seg.span.kind"
|
||||
>{{ seg.span.label }}</span>
|
||||
</template>
|
||||
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
|
||||
B-260618-01 虚拟滚动已移除,气泡内 <img> 随消息恒渲染无裁剪) -->
|
||||
<template v-if="msgImageParts(item.msg).length">
|
||||
@@ -899,7 +996,7 @@ defineExpose({
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
|
||||
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelativeZh(item.msg.timestamp) }}</span>
|
||||
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelative(item.msg.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- AI 消息 -->
|
||||
@@ -995,11 +1092,13 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
|
||||
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path 类 once/always/deny),
|
||||
store.approveToolCall 据 decision 走 authorizeDir,缺省走 approve。 -->
|
||||
<ToolCardList
|
||||
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
:toolCalls="item.msg.toolCalls"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
/>
|
||||
|
||||
@@ -1008,7 +1107,7 @@ defineExpose({
|
||||
v-if="!(isLastAi(item.msg) && store.state.streaming)"
|
||||
class="ai-msg-time"
|
||||
:title="formatDate(item.msg.timestamp)"
|
||||
>{{ formatRelativeZh(item.msg.timestamp) }}</span>
|
||||
>{{ formatRelative(item.msg.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1214,6 +1313,28 @@ defineExpose({
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
||||
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
||||
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
|
||||
.ai-msg-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
margin: 0 1px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
|
||||
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
|
||||
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
|
||||
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
|
||||
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
|
||||
|
||||
/* ── 消息时间戳(UX-2025-13) ── */
|
||||
.ai-msg-time {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { formatRelativeZh } from '@/utils/time'
|
||||
import { formatRelative } from '@/utils/time'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
@@ -59,9 +59,9 @@ function getProjectTaskCount(projectId: string): number {
|
||||
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
|
||||
}
|
||||
|
||||
// 相对时间复用 utils/time.formatRelativeZh(与 Tasks/AuditLog/MessageList 等同源,根治 NaN)
|
||||
// 相对时间复用 utils/time.formatRelative(与 Tasks/AuditLog/MessageList 等同源,根治 NaN)
|
||||
// 原 formatLastActivity 是其逐行复制,提取为单一来源。
|
||||
const formatLastActivity = formatRelativeZh
|
||||
const formatLastActivity = formatRelative
|
||||
|
||||
const displayProjects = computed(() =>
|
||||
store.projects.map(p => {
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
<h2 class="df-panel-title">{{ $t('dashboard.ideaPool') }}</h2>
|
||||
<router-link to="/ideas" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
||||
</div>
|
||||
<div class="ideas-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">{{ stats.total }}</span>
|
||||
<span class="stat-label">{{ $t('ideas.statsTotal') }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">{{ stats.pending }}</span>
|
||||
<span class="stat-label">{{ $t('ideas.statsPending') }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">{{ stats.promoted }}</span>
|
||||
<span class="stat-label">{{ $t('ideas.statsPromoted') }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">{{ stats.avgScore }}</span>
|
||||
<span class="stat-label">{{ $t('ideas.statsAvgScore') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="idea-rows">
|
||||
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row">
|
||||
<div class="idea-score-ring" :class="'ring-' + idea.tier">
|
||||
@@ -39,6 +57,22 @@ const displayIdeas = computed(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
// 灵感池统计概览 — 4 项关键指标(总数/待审/已晋升/平均分)。
|
||||
// score 可为 null(未评估),avgScore 仅对已评估灵感求均值,无则显示 '—'。
|
||||
const stats = computed(() => {
|
||||
const ideas = store.ideas
|
||||
const scored = ideas.filter(i => i.score != null)
|
||||
const avg = scored.length > 0
|
||||
? Math.round(scored.reduce((sum, i) => sum + (i.score ?? 0), 0) / scored.length)
|
||||
: null
|
||||
return {
|
||||
total: ideas.length,
|
||||
pending: ideas.filter(i => i.status === 'draft' || i.status === 'pending_review').length,
|
||||
promoted: ideas.filter(i => i.status === 'promoted').length,
|
||||
avgScore: avg == null ? '—' : avg,
|
||||
}
|
||||
})
|
||||
|
||||
function ideaStatusLabel(status: string): string {
|
||||
return t('dashboard.ideaStatus.' + status)
|
||||
}
|
||||
@@ -46,6 +80,35 @@ function ideaStatusLabel(status: string): string {
|
||||
|
||||
<style scoped>
|
||||
/* 仅迁入灵感行自身样式;df-panel/df-link 等通用面板类留 Dashboard.vue(多面板共享) */
|
||||
.ideas-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.stat-num {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
margin-top: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.idea-rows { display: flex; flex-direction: column; }
|
||||
.idea-row {
|
||||
display: flex;
|
||||
|
||||
@@ -5,16 +5,26 @@
|
||||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
||||
</div>
|
||||
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
|
||||
<p
|
||||
v-if="idea.description"
|
||||
class="detail-desc ai-md"
|
||||
v-html="renderedDesc"
|
||||
></p>
|
||||
<p v-else class="detail-desc">—</p>
|
||||
<template v-if="editing">
|
||||
<textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea>
|
||||
<div class="desc-edit-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="saveEdit">{{ $t('ideas.saveDesc') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="idea.description"
|
||||
class="detail-desc ai-md"
|
||||
v-html="renderedDesc"
|
||||
></p>
|
||||
<p v-else class="detail-desc">—</p>
|
||||
<button class="btn btn-ghost btn-sm desc-edit-btn" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
|
||||
</template>
|
||||
|
||||
<!-- 对抗式评估 -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t('ideas.evalModeHeuristic') }}</span></h3>
|
||||
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t(evalModeLabelKey()) }}</span></h3>
|
||||
<div v-if="adversarialEval" class="adversarial-eval">
|
||||
<!-- 正反方观点 -->
|
||||
<div class="debate-container">
|
||||
@@ -47,7 +57,7 @@
|
||||
<div class="analyst-conclusion">
|
||||
<h4>{{ $t('ideas.analystTitle') }}</h4>
|
||||
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
||||
{{ assessmentLabel(adversarialEval.recommendation) }}
|
||||
{{ localAssessmentLabel(adversarialEval.recommendation) }}
|
||||
</div>
|
||||
<p class="final-score">{{ $t('ideas.finalScore', { score: adversarialEval.final_score.toFixed(1) }) }}</p>
|
||||
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
|
||||
@@ -68,15 +78,95 @@
|
||||
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
|
||||
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
||||
</button>
|
||||
<div v-if="evalError" class="eval-error">⚠️ {{ evalError }}</div>
|
||||
<div v-if="evalError" class="eval-error">
|
||||
⚠️ {{ evalError }}
|
||||
<button class="btn-evaluate btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
|
||||
{{ $t('ideas.retryEval') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评估历史(版本时间线) -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.historyTitle') }}</h3>
|
||||
<div v-if="historyLoading" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyLoading') }}</div>
|
||||
<div v-else-if="evalHistory.length === 0" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyEmpty') }}</div>
|
||||
<div v-else class="eval-history-list">
|
||||
<div
|
||||
v-for="rec in evalHistory"
|
||||
:key="rec.id"
|
||||
class="eval-history-item"
|
||||
:class="{ expanded: expandedVersion === rec.version }"
|
||||
@click="toggleVersion(rec.version)"
|
||||
>
|
||||
<div class="history-row-main">
|
||||
<span class="version-badge">{{ $t('ideas.historyVersion') }} v{{ rec.version }}</span>
|
||||
<span v-if="rec.version === latestVersion" class="latest-tag">{{ $t('ideas.historyLatest') }}</span>
|
||||
<span class="history-eval-mode">{{ $t(evalModeLabelKeyFromStr(rec.evaluated_by)) }}</span>
|
||||
<span class="history-score">{{ rec.score == null ? '—' : rec.score.toFixed(1) }}</span>
|
||||
<span class="history-date">{{ formatDate(rec.evaluated_at) }}</span>
|
||||
</div>
|
||||
<div v-if="expandedVersion === rec.version" class="history-detail">
|
||||
<template v-if="historySummary(rec.ai_analysis)">
|
||||
<p class="history-summary">{{ historySummary(rec.ai_analysis)?.summary ?? '—' }}</p>
|
||||
<p v-if="historySummary(rec.ai_analysis)?.recommendation" class="history-rec">
|
||||
{{ historySummary(rec.ai_analysis)?.recommendation }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="history-summary">—</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联灵感 -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.relatedTitle') }}</h3>
|
||||
<!-- 展示态 -->
|
||||
<template v-if="!relatedEditing">
|
||||
<div v-if="relatedIds.length > 0" class="related-list">
|
||||
<template v-for="row in relatedRows" :key="row.id">
|
||||
<router-link
|
||||
v-if="row.title"
|
||||
class="related-chip"
|
||||
:to="`/ideas/${row.id}`"
|
||||
>{{ row.title }}</router-link>
|
||||
<span v-else class="related-chip related-chip-missing">#{{ row.id }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="startRelateEdit">{{ $t('ideas.relatedManage') }}</button>
|
||||
</template>
|
||||
<!-- 编辑态 -->
|
||||
<template v-else>
|
||||
<div v-if="candidateIdeas.length > 0" class="related-edit-list">
|
||||
<label
|
||||
v-for="idea in candidateIdeas"
|
||||
:key="idea.id"
|
||||
class="related-edit-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="relatedSelected.includes(idea.id)"
|
||||
@change="toggleSelect(idea.id)"
|
||||
/>
|
||||
<span>{{ idea.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
|
||||
<div class="related-edit-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="confirmRelate">{{ $t('ideas.relatedConfirm') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="cancelRelate">{{ $t('ideas.relatedCancel') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 传统评分雷达图 -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
|
||||
<div class="radar-chart" v-if="parseScores(idea).length > 0">
|
||||
<div class="radar-row" v-for="dim in parseScores(idea)" :key="dim.name">
|
||||
<div class="radar-chart" v-if="parseScores(idea.scores).length > 0">
|
||||
<div class="radar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
|
||||
<span class="radar-label">{{ dim.name }}</span>
|
||||
<div class="radar-bar-track">
|
||||
<div class="radar-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
|
||||
@@ -117,6 +207,13 @@
|
||||
>
|
||||
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
|
||||
</button>
|
||||
<router-link
|
||||
v-if="idea.promoted_to"
|
||||
class="btn btn-primary"
|
||||
:to="`/projects/${idea.promoted_to}`"
|
||||
>
|
||||
🚀 {{ $t('ideas.promotedProject') }} →
|
||||
</router-link>
|
||||
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
|
||||
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
|
||||
</button>
|
||||
@@ -126,11 +223,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { parseTags } from '../../stores/knowledge'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { useRendered } from '../../composables/useMarkdown'
|
||||
import type { IdeaRecord, IdeaStatus } from '../../api/types'
|
||||
import { ideaApi } from '../../api'
|
||||
import { formatDate } from '../../utils/time'
|
||||
import { parseScores, assessmentClass, assessmentLabel } from '../../utils/ideaEval'
|
||||
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
idea: IdeaRecord
|
||||
@@ -146,9 +247,82 @@ const emit = defineEmits<{
|
||||
(e: 'promote'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'status-change', value: IdeaStatus): void
|
||||
(e: 'update-desc', value: string): void
|
||||
(e: 'update-related', ids: string[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useProjectStore()
|
||||
|
||||
// 描述可编辑模式
|
||||
const editing = ref(false)
|
||||
const editDesc = ref('')
|
||||
|
||||
function startEdit() {
|
||||
editDesc.value = props.idea.description ?? ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
emit('update-desc', editDesc.value)
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
// ===== 关联灵感(related_ids JSON 数组字符串)=====
|
||||
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
|
||||
const relatedIds = computed<string[]>(() => {
|
||||
const raw = props.idea.related_ids
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 反查标题:relatedIds 在 store.ideas 中找对应灵感(找不到留待展示态显 #id)。
|
||||
// 项3 DRY/性能:原模板 v-for 内对每个 id 调 relatedIdeas.find 两次(O(n)×2);
|
||||
// 改为一次性构建 {id, title|null} 数组(保留原始顺序含缺失项),模板直接 v-for 对象,O(1)。
|
||||
const relatedRows = computed<{ id: string; title: string | null }[]>(() => {
|
||||
// store.ideas → Map 一次构建,O(1) 查找,避免对每个 relatedId 线性扫 store.ideas
|
||||
const byId = new Map(store.ideas.map(i => [i.id, i.title] as const))
|
||||
return relatedIds.value.map(id => ({ id, title: byId.get(id) ?? null }))
|
||||
})
|
||||
|
||||
// 编辑态
|
||||
const relatedEditing = ref(false)
|
||||
const relatedSelected = ref<string[]>([])
|
||||
|
||||
// 可选关联的其他灵感(排除当前灵感自身)
|
||||
const candidateIdeas = computed(() => store.ideas.filter(i => i.id !== props.idea.id))
|
||||
|
||||
function startRelateEdit() {
|
||||
relatedSelected.value = [...relatedIds.value]
|
||||
relatedEditing.value = true
|
||||
}
|
||||
|
||||
function confirmRelate() {
|
||||
emit('update-related', relatedSelected.value)
|
||||
relatedEditing.value = false
|
||||
}
|
||||
|
||||
function cancelRelate() {
|
||||
relatedEditing.value = false
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
const idx = relatedSelected.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
relatedSelected.value.splice(idx, 1)
|
||||
} else {
|
||||
relatedSelected.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
||||
function statusLabelKey(status: IdeaStatus): string {
|
||||
@@ -161,23 +335,7 @@ const { rendered: renderedDesc } = useRendered(
|
||||
() => props.idea.description ?? '',
|
||||
)
|
||||
|
||||
interface ScoreDimension { name: string; score: number }
|
||||
|
||||
function parseScores(idea: IdeaRecord): ScoreDimension[] {
|
||||
if (!idea.scores) return []
|
||||
try {
|
||||
const parsed = JSON.parse(idea.scores)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed).map(([name, score]) => ({
|
||||
name,
|
||||
score: typeof score === 'number' ? score : 0,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
// parseScores / assessmentClass / assessmentLabel 抽到 ../../utils/ideaEval 复用(消除与 ProjectDetail 的 DRY 重复)。
|
||||
|
||||
interface AdversarialEval {
|
||||
positive_strength: number
|
||||
@@ -190,6 +348,7 @@ interface AdversarialEval {
|
||||
positive: { thesis: string; evidence: string[] }
|
||||
negative: { thesis: string; evidence: string[] }
|
||||
analyst: { summary: string }
|
||||
evaluated_by?: string
|
||||
}
|
||||
|
||||
// 对抗式评估数据持久化在 ai_analysis JSON 字段中,前端解析渲染
|
||||
@@ -202,25 +361,18 @@ const adversarialEval = computed<AdversarialEval | null>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function assessmentClass(recommendation: string) {
|
||||
// 映射到 CSS 定义的 badge 颜色类(.immediate/.soon/.conditional/.revised/.defer/.cancel)
|
||||
const map: Record<string, string> = {
|
||||
'immediate action': 'immediate',
|
||||
'soon': 'soon',
|
||||
'with resources': 'conditional',
|
||||
'research more': 'revised',
|
||||
'monitor': 'defer',
|
||||
'cancel': 'cancel',
|
||||
}
|
||||
return map[recommendation.toLowerCase()] ?? 'conditional'
|
||||
// 评估深度标签动态化(P0):根据 evaluated_by 字段映射 i18n key
|
||||
// 'Llm' → LLM 深度评估;'HeuristicFallback' → 启发式降级;
|
||||
// 其他(undefined / 'Heuristic' / 老数据无此字段)→ 启发式
|
||||
// 映射逻辑与 evalModeLabelKeyFromStr 同,这里直接透传避免重复(项2 DRY)
|
||||
function evalModeLabelKey(): string {
|
||||
return evalModeLabelKeyFromStr(adversarialEval.value?.evaluated_by)
|
||||
}
|
||||
|
||||
function assessmentLabel(recommendation: string) {
|
||||
const key = `ideas.assessment.${recommendation}`
|
||||
// 未命中 i18n key 时回退到原始 recommendation 字符串
|
||||
const translated = t(key)
|
||||
return translated === key ? recommendation : translated
|
||||
}
|
||||
// assessmentClass 直接复用 ../../utils/ideaEval(无 i18n 依赖,模板可直调)。
|
||||
// assessmentLabel 需要 t 注入,这里留一个绑定 t 的薄包装,消除映射逻辑重复(项1 DRY)。
|
||||
const localAssessmentLabel = (recommendation: string): string =>
|
||||
assessmentLabel(t, recommendation)
|
||||
|
||||
function sentimentClass(sentiment: number) {
|
||||
// 统一三档(FR-C2: 原 >=0 与模板 >0 矛盾,net_sentiment=0 时文案负面样式 positive)
|
||||
@@ -232,6 +384,63 @@ function sentimentClass(sentiment: number) {
|
||||
function onStatusChange(e: Event) {
|
||||
emit('status-change', (e.target as HTMLSelectElement).value as IdeaStatus)
|
||||
}
|
||||
|
||||
// ===== 评估历史(版本时间线)=====
|
||||
const evalHistory = ref<IdeaEvaluationRecord[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const expandedVersion = ref<number | null>(null)
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
evalHistory.value = await ideaApi.listEvaluations(props.idea.id)
|
||||
} catch {
|
||||
evalHistory.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 最新版本 = version 最大者(后端按 DESC 返回,首项即最新;这里用 max 兜底)
|
||||
const latestVersion = computed(() =>
|
||||
evalHistory.value.reduce((max, r) => Math.max(max, r.version), -1),
|
||||
)
|
||||
|
||||
function toggleVersion(v: number) {
|
||||
expandedVersion.value = expandedVersion.value === v ? null : v
|
||||
}
|
||||
|
||||
// 针对字符串 evaluated_by 映射 i18n key(与 evalModeLabelKey 同映射规则,接收裸字符串)
|
||||
function evalModeLabelKeyFromStr(source: string | null | undefined): string {
|
||||
if (source === 'Llm') return 'ideas.evalModeLlm'
|
||||
if (source === 'HeuristicFallback') return 'ideas.evalModeFallback'
|
||||
return 'ideas.evalModeHeuristic'
|
||||
}
|
||||
|
||||
// 解析历史 ai_analysis JSON,取 summary / recommendation(对抗式评估格式)。
|
||||
// 老数据或非 LLM 评估可能无该结构 → 返回 null。
|
||||
interface HistorySummary { summary: string | null; recommendation: string | null }
|
||||
function historySummary(aiAnalysis: string | null | undefined): HistorySummary | null {
|
||||
if (!aiAnalysis) return null
|
||||
try {
|
||||
const parsed = JSON.parse(aiAnalysis)
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const summary = typeof obj.summary === 'string' ? obj.summary
|
||||
: (typeof obj.analyst === 'object' && obj.analyst !== null
|
||||
&& typeof (obj.analyst as Record<string, unknown>).summary === 'string'
|
||||
? ((obj.analyst as Record<string, unknown>).summary as string)
|
||||
: null)
|
||||
const recommendation = typeof obj.recommendation === 'string' ? obj.recommendation : null
|
||||
if (!summary && !recommendation) return null
|
||||
return { summary, recommendation }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadHistory)
|
||||
watch(() => props.idea.id, loadHistory)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -486,6 +695,85 @@ function onStatusChange(e: Event) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ===== 评估历史(版本时间线)===== */
|
||||
.eval-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.eval-history-item {
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.eval-history-item:hover {
|
||||
background: var(--df-bg-card);
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.eval-history-item.expanded {
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.history-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
.version-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.12);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.latest-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(100, 255, 218, 0.15);
|
||||
color: var(--df-success);
|
||||
}
|
||||
.history-eval-mode {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(255, 217, 61, 0.12);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.history-score {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
min-width: 32px;
|
||||
}
|
||||
.history-date {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
.history-detail {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.history-summary {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.history-rec {
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
/* ===== 雷达图(div 模拟) ===== */
|
||||
.radar-chart {
|
||||
display: flex;
|
||||
@@ -535,6 +823,37 @@ function onStatusChange(e: Event) {
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 关联灵感 ===== */
|
||||
.related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.related-chip {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.related-chip:hover { background: rgba(108, 99, 255, 0.2); }
|
||||
.related-chip-missing { cursor: default; opacity: 0.6; }
|
||||
|
||||
.related-edit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.related-edit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.related-edit-item input { cursor: pointer; }
|
||||
.related-edit-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
@@ -549,6 +868,40 @@ function onStatusChange(e: Event) {
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* 小尺寸按钮(描述编辑/重试等) */
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.desc-edit-btn { margin-bottom: var(--df-gap-page); margin-top: 4px; }
|
||||
.desc-edit-actions { display: flex; gap: 8px; margin-bottom: var(--df-gap-page); margin-top: 8px; }
|
||||
|
||||
/* 描述编辑 textarea */
|
||||
.detail-desc-edit {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.detail-desc-edit:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-accent);
|
||||
background: var(--df-bg-raised);
|
||||
}
|
||||
|
||||
/* 评估失败重试按钮(内联于错误提示) */
|
||||
.btn-retry {
|
||||
margin-left: 8px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ===== 状态管理 ===== */
|
||||
.status-controls {
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -77,13 +77,26 @@ const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* 启动审批超时计时器(幂等:同 id 重复启动不重建)。
|
||||
* 到点回调:调 ai_approve(id, false) 自动拒绝 + push 系统错误消息。
|
||||
* 到点回调按 kind 分派拒绝路径 + push 系统错误消息:
|
||||
* - kind='risk'(risk 类审批,如 write_file):调 aiApi.approve(id, false) → 后端 ai_approve(kind==Risk)。
|
||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(id, 'deny') → 后端 ai_authorize_dir 续 loop 返 Err。
|
||||
* 修复回归:path 类若错调 ai_approve,后端 ai_approve 校验 kind==Risk 直接拒,IPC 不进续 loop → 卡死。
|
||||
* memory: aiShared.startApprovalTimer 回调按 kind 分派(risk→approve / path→authorizeDir('deny')),
|
||||
* path 类错误复用 risk 的 ai_approve 致后端 kind 校验拒、续 loop 不触发 → 卡死。
|
||||
*/
|
||||
export function startApprovalTimer(toolCallId: string, toolName: string): void {
|
||||
export function startApprovalTimer(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
kind: 'risk' | 'path' = 'risk',
|
||||
): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
aiApi.approve(toolCallId, false).catch(e => {
|
||||
// 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死
|
||||
const denyP = kind === 'path'
|
||||
? aiApi.authorizeDir(toolCallId, 'deny')
|
||||
: aiApi.approve(toolCallId, false)
|
||||
denyP.catch(e => {
|
||||
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
|
||||
})
|
||||
state.messages.push({
|
||||
|
||||
114
src/composables/ai/streamingGuard.ts
Normal file
114
src/composables/ai/streamingGuard.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
//! streaming 状态写收敛 guard(对齐 memory [[devflow-generating-statemachine]] 前端落地)。
|
||||
//!
|
||||
//! 背景:`state.streaming`(当前活跃视图的流式态)原本散布在 8+ 处直接赋值
|
||||
//! (useAiSend/useAiEvents/useAiConversations/useAiWindow/useAiPanel/useAiStream),
|
||||
//! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 →
|
||||
//! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。
|
||||
//!
|
||||
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做三件事:
|
||||
//! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝——
|
||||
//! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗)
|
||||
//! 记 warn 日志供排查卡死。
|
||||
//! 2. 联动 generatingConvs:value=true 且带 convId → add(convId);value=false 且带 convId
|
||||
//! → delete(convId)。不带 convId 的复位(全局兜底)不动 generatingConvs,因其由
|
||||
//! AiCompleted/AiError 按 conversation_id 精确管理(避免误删并发会话生成态)。
|
||||
//! 3. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
|
||||
//! 不校验不联动,回退原散布语义(灰度/回退开关)。
|
||||
//!
|
||||
//! 注:本 guard 是「写收敛 + 可观测 + 联动」三合一,不是拒绝式状态机——
|
||||
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
|
||||
const STREAMING_GUARD_FLAG = 'df-ai-generating-statemachine'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/// guard 是否启用(读 appSettings,默认开=true)。
|
||||
///
|
||||
/// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更,
|
||||
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现联动 generatingConvs 引入回归,
|
||||
/// 用户可在设置里关掉回退原散布语义)。
|
||||
function isGuardEnabled(): boolean {
|
||||
return appSettings.get<boolean>(STREAMING_GUARD_FLAG, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。
|
||||
* @param value 目标流式态
|
||||
* @param opts.convId 关联会话 id。value=true 时 add 进 generatingConvs;
|
||||
* value=false 时 delete 出 generatingConvs。省略则不动 generatingConvs
|
||||
* (全局兜底复位场景,generatingConvs 由 AiCompleted/AiError 精确管理)。
|
||||
* @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。
|
||||
*
|
||||
* 行为:
|
||||
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不联动不校验)。
|
||||
* - flag 开 → 幂等检测(value===当前值记 debug)、联动 generatingConvs、写日志。
|
||||
*/
|
||||
export function setStreaming(
|
||||
value: boolean,
|
||||
opts: { convId?: string | null; reason?: string } = {},
|
||||
): void {
|
||||
const { convId, reason } = opts
|
||||
|
||||
// flag 关:回退散布语义,直接赋值不动其他。
|
||||
if (!isGuardEnabled()) {
|
||||
state.streaming = value
|
||||
return
|
||||
}
|
||||
|
||||
const prev = state.streaming
|
||||
|
||||
// 幂等观测:同值重复写不拒绝(resetStreamWatchdog 等副作用场景需合法重入),
|
||||
// 仅记 debug 日志供排查「为何重复置位」的卡死场景。
|
||||
if (prev === value) {
|
||||
console.debug(
|
||||
`[AI-Guard] setStreaming 幂等(prev=${prev}, convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`,
|
||||
)
|
||||
} else {
|
||||
console.debug(
|
||||
`[AI-Guard] setStreaming ${prev}→${value} (convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`,
|
||||
)
|
||||
}
|
||||
|
||||
// 联动 generatingConvs:仅在有 convId 时联动。
|
||||
// value=true:convId 入 Set(标记该会话生成中,侧栏/分离窗口据此显示)。
|
||||
// value=false:convId 出 Set(该会话收尾)。无 convId 的全局复位不动 Set
|
||||
// (并发会话生成态由各自 AiCompleted/AiError 精确 delete 管理,全局清会误杀)。
|
||||
if (convId) {
|
||||
if (value) {
|
||||
state.generatingConvs.add(convId)
|
||||
} else {
|
||||
state.generatingConvs.delete(convId)
|
||||
}
|
||||
}
|
||||
|
||||
state.streaming = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制复位 streaming=false 的兜底入口(panic/卡死恢复用)。
|
||||
*
|
||||
* 与 setStreaming(false) 区别:本函数额外清 currentText + queue,
|
||||
* 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。
|
||||
*
|
||||
* TD-260621-01 per-conv:看门狗超时携带 convId 时,仅清该 conv 的 generatingConvs
|
||||
* (经 setStreaming 联动 delete),不再全清误杀并发会话生成态。不带 convId(全局兜底,
|
||||
* 如 stopListener 卸载场景)时 generatingConvs 不动,由各自会话收尾事件精确管理。
|
||||
*
|
||||
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
|
||||
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
|
||||
* 而超时仅发生在该 conv 长时间无数据,此场景 currentText 多为空或已 flush,
|
||||
* 实际误伤面远小于 generatingConvs 全清(队列 queue 的清理由 F-09 统一改,见 TD-260621-01 注释)。
|
||||
*
|
||||
* @param reason 兜底来源(日志可观测)
|
||||
* @param convId 关联会话 id(看门狗 per-conv 超时传;省略=全局兜底)
|
||||
*/
|
||||
export function forceResetStreaming(reason: string, convId?: string): void {
|
||||
console.warn(`[AI-Guard] forceResetStreaming(卡死兜底): ${reason} (convId=${convId ?? 'none'})`)
|
||||
setStreaming(false, { convId: convId ?? null, reason })
|
||||
state.currentText = ''
|
||||
state.queue = []
|
||||
}
|
||||
@@ -24,27 +24,56 @@ import { findToolCall } from './aiShared'
|
||||
*/
|
||||
const _pendingApprovalIds = new Set<string>()
|
||||
|
||||
/** 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
|
||||
/**
|
||||
* 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
|
||||
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
|
||||
* B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
||||
*
|
||||
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
|
||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。
|
||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
||||
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
|
||||
* approved=true→放行执行,approved=false→拒绝。
|
||||
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
|
||||
* **注意**:决策分派由 tc.kind 决定,与调用方是否传 decision 入参无关——decision 仅 path 类消费,
|
||||
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
|
||||
*
|
||||
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
|
||||
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
||||
*/
|
||||
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') {
|
||||
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||
if (_pendingApprovalIds.has(toolCallId)) return
|
||||
_pendingApprovalIds.add(toolCallId)
|
||||
try {
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||
const tc = findToolCall(toolCallId)
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
|
||||
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/always/deny);
|
||||
// 其余(risk/缺省)走 approve(approve/reject)。
|
||||
const isPathKind = !!tc && tc.kind === 'path'
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
|
||||
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||
// 避免新增独立计时器与状态分支)
|
||||
resetStreamWatchdog()
|
||||
// TD-260621-03 per-conv:看门狗 timer 用**审批工具调用所属 conv**——tc 来自 findToolCall 反扫
|
||||
// state.messages(当前活跃会话的单例视图),故 tc 所属 conv 即当前 activeConversationId。
|
||||
// 切会话时 switchConversation 已整体替换 state.messages+pendingApprovals(单例,非 per-conv Map),
|
||||
// 旧 conv 的 tc 不再可达,故 activeConversationId 是 tc 所属 conv 的正确等价表达。
|
||||
// 复用此 convId 供 reset 与 catch 内 clear 对称使用(同会话 pair,不跨会话误清)。
|
||||
const approvalConvId = state.activeConversationId || undefined
|
||||
resetStreamWatchdog(approvalConvId)
|
||||
try {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
if (isPathKind) {
|
||||
// path 类:decision 缺省兜底 deny(调用方未传时安全侧拒绝,不静默放行)。
|
||||
await aiApi.authorizeDir(toolCallId, decision ?? 'deny')
|
||||
} else {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
}
|
||||
} catch (e) {
|
||||
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
|
||||
clearStreamWatchdog()
|
||||
// TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。
|
||||
clearStreamWatchdog(approvalConvId)
|
||||
state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失
|
||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||
@@ -60,14 +89,29 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
|
||||
/**
|
||||
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
|
||||
* 非目录聚合——path 类每条独立决策,不合并同目录)。
|
||||
*
|
||||
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
|
||||
* 批量操作语义:
|
||||
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权仅本次,
|
||||
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
|
||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||
*/
|
||||
async function batchApprove(decision: 'approve' | 'reject') {
|
||||
const approved = decision === 'approve'
|
||||
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
||||
const ids = [...state.pendingApprovals].map(p => p.id)
|
||||
for (const id of ids) {
|
||||
const items = [...state.pendingApprovals]
|
||||
for (const p of items) {
|
||||
try {
|
||||
await approveToolCall(id, approved)
|
||||
if (p.kind === 'path') {
|
||||
// path 类:approve→once(仅本次,不批量写持久白名单), reject→deny
|
||||
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
|
||||
} else {
|
||||
// risk 类:approve/reject boolean
|
||||
await approveToolCall(p.id, decision === 'approve')
|
||||
}
|
||||
} catch {
|
||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||
// 继续处理剩余项
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
@@ -70,7 +71,7 @@ async function newConversation() {
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
setStreaming(false, { reason: 'newConversation' })
|
||||
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
|
||||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||||
@@ -96,6 +97,12 @@ export async function switchConversation(id: string) {
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
state.activeConversationId = id
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
// P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算,
|
||||
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
|
||||
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
|
||||
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
|
||||
// 走 setStreaming guard 收敛写入口(convId=id 联动幂等——has(id) 决定值,add/delete 幂等无副作用)。
|
||||
setStreaming(state.generatingConvs.has(id), { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
@@ -181,18 +188,41 @@ export async function switchConversation(id: string) {
|
||||
state.currentText = ''
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了 pending_approvals,
|
||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
||||
// 阶段4:按 IPC 返的 kind 渲染——'path' 类显 once/always/deny(tc.kind='path' + 推 path/dir 文案),
|
||||
// 'risk' 类显 approve/reject(tc.kind='risk'/缺省)。对齐阶段3b 统一审批模型(两 kind 都可恢复)。
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (pending.length) {
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复 tc/pendingApprovals 按类型渲染
|
||||
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||
const restored: AiToolCallInfo[] = []
|
||||
for (const m of state.messages) {
|
||||
for (const tc of (m.toolCalls || [])) {
|
||||
if (pendingIds.has(tc.id)) {
|
||||
tc.status = 'pending_approval'
|
||||
restored.push({ id: tc.id, name: tc.name, args: tc.args, status: 'pending_approval' })
|
||||
const kind = pendingKindMap.get(tc.id) ?? 'risk'
|
||||
tc.kind = kind
|
||||
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
|
||||
// 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。
|
||||
if (kind === 'path') {
|
||||
const p = (tc.args as { path?: string } | null)?.path
|
||||
tc.path = p
|
||||
tc.dir = p ?? undefined
|
||||
tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' })
|
||||
}
|
||||
restored.push({
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
status: 'pending_approval',
|
||||
kind,
|
||||
path: tc.path,
|
||||
dir: tc.dir,
|
||||
reason: tc.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -95,14 +96,22 @@ function clearAllToolSlowTimers(): void {
|
||||
// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出,
|
||||
// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。
|
||||
// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定),
|
||||
// 故用 boolean ref 而非数组,语义更精确。
|
||||
export const pendingMaxRounds = ref(false)
|
||||
// 故用单值 ref 而非数组,语义更精确。
|
||||
//
|
||||
// TD-260621-02 per-conv:原 ref(false)(boolean)无 convId 维度,F-09 多会话并发下 A 达 max
|
||||
// 挂起时切到 B 会话,MaxRoundsCard 守卫(仅判 isViewingGenerating)会把 A 的操作卡错显于 B。
|
||||
// 改 ref<string|null>(存挂起 convId,null=无挂起)。消费方(MaxRoundsCard)守卫改精确比对
|
||||
// pendingMaxRounds === activeConversationId。set 按事件 conversation_id 赋值;
|
||||
// clear(AiCompleted/AiError)仅当 pendingMaxRounds===该 convId 才清 null(避免清错会话的挂起)。
|
||||
export const pendingMaxRounds = ref<string | null>(null)
|
||||
|
||||
// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。
|
||||
//
|
||||
// 同 pendingMaxRounds 模式:模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件
|
||||
// 直接 import 读。一次只追踪一个挂起询问(后端单 tool_call_id 挂起,用户决策后 try_continue),
|
||||
// 故用对象 ref 而非数组,语义更精确。null = 无挂起。
|
||||
// path_auth 审批链阶段1(解卡死):单 ref → 数组。根因——同轮多个文件工具落不同未授权目录时,
|
||||
// 后端连发多条 AiDirAuthRequired,单 ref 后到覆盖先到,先到的弹窗丢失→授权无法到达→loop 卡死。
|
||||
// 改数组 push 多条并存,各 case(AiApprovalResult/AiCompleted/AiError)按 id filter 移除。
|
||||
// 开关 df-ai-multi-pending-auth 控制多挂起并存(true,默认)vs 单 ref 覆盖(false,兜底回退)。
|
||||
// 模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件直接 import 读。
|
||||
export interface PendingDirAuth {
|
||||
id: string
|
||||
tool: string
|
||||
@@ -110,7 +119,7 @@ export interface PendingDirAuth {
|
||||
dir: string
|
||||
conversationId?: string
|
||||
}
|
||||
export const pendingDirAuth = ref<PendingDirAuth | null>(null)
|
||||
export const pendingDirAuths = ref<PendingDirAuth[]>([])
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
@@ -146,6 +155,49 @@ function isShowTokenUsage(): boolean {
|
||||
return appSettings.get<boolean>('df-show-token-usage', false)
|
||||
}
|
||||
|
||||
/** path_auth 多挂起并行开关(appSettings key `df-ai-multi-pending-auth`)。
|
||||
* path_auth 审批链阶段1:解卡死核心——同轮多个文件工具落不同未授权目录时,后端会连发多条
|
||||
* AiDirAuthRequired。单 ref 会被后到的事件覆盖,先到的弹窗丢失→授权无法到达→loop 卡死。
|
||||
* 开(true,默认):pendingDirAuths 数组,push 多条并存,按 id 定位消费。
|
||||
* 关(false,兜底回退):退化单 ref 语义——后到覆盖先到(push 前清空数组),行为对齐旧版。
|
||||
* 回退路径:appSettings.set('df-ai-multi-pending-auth', false) 即恢复单 ref 行为,无需改代码。 */
|
||||
function isMultiPendingAuth(): boolean {
|
||||
return appSettings.get<boolean>('df-ai-multi-pending-auth', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* path_auth 审批链阶段3b(统一审批模型)开关(appSettings key `df-ai-unified-approval`)。
|
||||
*
|
||||
* 决策(plan 阶段3b):状态层合(单 pendingApprovals + kind 字段)+ 决策层分(path 走 authorizeDir /
|
||||
* risk 走 approve)。开关控制 path 类挂起是否归一进 pendingApprovals 带 kind='path' 字段,
|
||||
* 由 ToolCard 内联审批(once/always/deny);关时回退 DirAuthDialog 老链路(独立弹窗)。
|
||||
*
|
||||
* 开(true,默认):AiDirAuthRequired case 把 path 挂起转成 pendingApprovals 项(kind='path'),
|
||||
* 不再 push pendingDirAuths;DirAuthDialog 在 AiChat 中按开关判断不挂载。ToolCard 按 kind
|
||||
* 显 once/always/deny 三按钮,调 aiApi.authorizeDir。
|
||||
* 关(false,兜底回退):退回阶段1老链路——AiDirAuthRequired push pendingDirAuths,DirAuthDialog
|
||||
* 渲染弹窗,与阶段3a 后端合(单 HashMap + kind 字段)兼容(后端语义层已合,前端 UI 不切)。
|
||||
* 回退路径:appSettings.set('df-ai-unified-approval', false) 即恢复 DirAuthDialog 弹窗,无需改代码。
|
||||
*/
|
||||
function isUnifiedApproval(): boolean {
|
||||
return appSettings.get<boolean>('df-ai-unified-approval', true)
|
||||
}
|
||||
|
||||
/** 向 pendingDirAuths 追加一条(开关控制:多挂起并存 vs 单 ref 覆盖) */
|
||||
function pushPendingDirAuth(p: PendingDirAuth): void {
|
||||
if (isMultiPendingAuth()) {
|
||||
pendingDirAuths.value.push(p)
|
||||
} else {
|
||||
// 兜底单 ref 语义:后到覆盖先到(行为对齐旧版,数组只留最新一条)
|
||||
pendingDirAuths.value = [p]
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 id 从 pendingDirAuths 移除一条 */
|
||||
function removePendingDirAuth(id: string): void {
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)────────────────────────────
|
||||
//
|
||||
// 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。
|
||||
@@ -216,32 +268,74 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
|
||||
case 'AiMaxRoundsReached':
|
||||
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
|
||||
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
|
||||
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
|
||||
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
|
||||
pendingMaxRounds.value = true
|
||||
// TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
|
||||
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
|
||||
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
|
||||
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
|
||||
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
|
||||
return true
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
|
||||
// 置 pendingDirAuth 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
|
||||
// 同时把工具卡置 pending_approval 态(与 AiApprovalRequired 一致,复用 ToolCard 渲染)。
|
||||
clearStreamWatchdog()
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.tool,
|
||||
args: { path: event.path },
|
||||
status: 'pending_approval',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'pending_approval'
|
||||
// push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
|
||||
// F-260620 根治(错调 ai_approve 致卡死):path_auth 挂起**不**置工具卡 pending_approval——
|
||||
// 工具卡 pending_approval 态有审批按钮调 ai_approve(为 RiskLevel 审批设计),path_auth 错调
|
||||
// ai_approve 会被后端拦(path_auth 回滚+Err)→ 前端转圈卡死(authz-debug.log 铁证:同 tool_call_id
|
||||
// ai_approve+ai_authorize_dir 双调)。path_auth 只走 DirAuthDialog(ai_authorize_dir),
|
||||
// 工具卡保持 running(等授权结果,授权后 AiToolCallCompleted 转 completed)。
|
||||
//
|
||||
// path_auth 审批链阶段3b(统一审批模型):开关 df-ai-unified-approval 开时,path 挂起归一进
|
||||
// pendingApprovals 带 kind='path',由 ToolCard 内联显 once/always/deny 三按钮(调 authorizeDir),
|
||||
// 消除独立 DirAuthDialog 弹窗(单真相源:状态层合)。开关关时回退阶段1老链路(push pendingDirAuths,
|
||||
// DirAuthDialog 渲染)。两路共存,兜底可随时回退。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearToolSlowTimer(event.id)
|
||||
pendingDirAuth.value = {
|
||||
id: event.id,
|
||||
tool: event.tool,
|
||||
path: event.path,
|
||||
dir: event.dir,
|
||||
conversationId: event.conversation_id,
|
||||
// 开关开:归一进 pendingApprovals(kind='path'),驱动 ToolCard 内联审批。
|
||||
// 同 id 幂等:GLM anthropic_compat id 不稳或重发时,已有同 id 不重复 push(防残留重复卡)。
|
||||
if (isUnifiedApproval()) {
|
||||
if (!state.pendingApprovals.some(p => p.id === event.id)) {
|
||||
state.pendingApprovals.push({
|
||||
id: event.id,
|
||||
name: event.tool,
|
||||
args: { path: event.path },
|
||||
status: 'pending_approval',
|
||||
kind: 'path',
|
||||
dir: event.dir,
|
||||
path: event.path,
|
||||
// path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案);
|
||||
// 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。
|
||||
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
|
||||
})
|
||||
// 同步改对应工具卡的 tc(让 ToolCard 显 path 类审批按钮 once/always/deny + status-dot pending 色)。
|
||||
// F-260620 注释"工具卡保持 running"是为防 path_auth 错调 ai_approve 卡死——统一模式后 path 类
|
||||
// 调 authorizeDir 不再错调,故可安全进 pending_approval(与 risk 类 AiApprovalRequired 同款流转)。
|
||||
// 开关关(兜底)时仍保持老语义(tc 不动,走 DirAuthDialog)。
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'pending_approval'
|
||||
tc.kind = 'path'
|
||||
tc.dir = event.dir
|
||||
tc.path = event.path
|
||||
tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path })
|
||||
}
|
||||
// path 类挂起同样启动审批超时计时器(5min 不处理自动拒),与 risk 类一致。
|
||||
// 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。
|
||||
startApprovalTimer(event.id, event.tool, 'path')
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。
|
||||
// pushPendingDirAuth 据开关(df-ai-multi-pending-auth)决定多挂起并存(默认)还是单 ref 覆盖兜底。
|
||||
if (!pendingDirAuths.value.some(p => p.id === event.id)) {
|
||||
pushPendingDirAuth({
|
||||
id: event.id,
|
||||
tool: event.tool,
|
||||
path: event.path,
|
||||
dir: event.dir,
|
||||
conversationId: event.conversation_id,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -298,12 +392,16 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
|
||||
case 'AiApprovalRequired': {
|
||||
clearStreamWatchdog() // 等用户审批,不计超时
|
||||
clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计超时
|
||||
// path_auth 审批链阶段3b:event.kind 缺省默认 'risk'(后端阶段3a wire 未加 kind 字段,
|
||||
// 老后端不传即 risk 类)。event.kind 为 'path' 时理论上 path 挂起也走此事件(后端未来可统一),
|
||||
// 当前阶段3a 后端 path 挂起仍走独立 AiDirAuthRequired 事件,故此处 kind 恒 'risk'。
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
kind: event.kind ?? 'risk',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
@@ -316,7 +414,8 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
// B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
|
||||
startApprovalTimer(event.id, event.name)
|
||||
// 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。
|
||||
startApprovalTimer(event.id, event.name, 'risk')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -336,12 +435,16 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
} else {
|
||||
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
|
||||
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
|
||||
// path_auth 审批链:path 类(once/always 通过)与 risk 类共用此分支。
|
||||
// 补 clearApprovalTimer 防 timer 到期错调:授权通过但超时计时器未清,5min 后仍会触发拒绝回调
|
||||
// (path 类调 authorizeDir('deny')/risk 类调 ai_approve(false))与已通过的执行态冲突。risk 类拒绝分支(:422)已清,
|
||||
// 此处通过分支原漏清(回归:统一审批开关开后 path 类挂起也挂 timer,通过分支须对称清)。
|
||||
clearApprovalTimer(event.id)
|
||||
}
|
||||
// F-260619-03 Phase B: 路径授权挂起的工具收到 ApprovalResult(once/always 通过 / deny 拒绝)
|
||||
// → 清弹窗(后端 ai_authorize_dir 已 try_continue 续 loop)。
|
||||
if (pendingDirAuth.value && pendingDirAuth.value.id === event.id) {
|
||||
pendingDirAuth.value = null
|
||||
}
|
||||
// F-260619-03 Phase B + path_auth 审批链阶段1:路径授权挂起的工具收到 ApprovalResult
|
||||
// (once/always 通过 / deny 拒绝)→ 按 id 从 pendingDirAuths 移除该条(后端 ai_authorize_dir
|
||||
// 已 try_continue 续 loop)。数组化后只清这一条,不影响同轮其他未决策的挂起项。
|
||||
removePendingDirAuth(event.id)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -354,15 +457,22 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
case 'AiCompleted': {
|
||||
clearStreamWatchdog()
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
|
||||
pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留)
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
|
||||
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
|
||||
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
|
||||
const doneConv2 = event.conversation_id || ''
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === doneConv2)
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
|
||||
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
|
||||
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
|
||||
@@ -405,13 +515,13 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
clearStreamWatchdog()
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
|
||||
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
|
||||
// UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡,
|
||||
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
|
||||
flushCurrentText()
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
|
||||
@@ -419,8 +529,13 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
|
||||
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
|
||||
state.pendingApprovals = []
|
||||
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
|
||||
pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
|
||||
const errConv2 = event.conversation_id || ''
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
|
||||
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
|
||||
const errConv = event.conversation_id || ''
|
||||
if (errConv) {
|
||||
@@ -451,6 +566,8 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
|
||||
export function handleEvent(event: AiChatEvent) {
|
||||
const convId = event.conversation_id
|
||||
// 注:F-260620 path_auth 审批链根治卡死后,原 [AI-DIRAUTH-DIAG] 高频诊断 console.log 已删除
|
||||
// (使命完成;热路径污染 devtools)。后续如需事件流诊断用 df-ai-event-trace 开关控制。
|
||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
||||
// 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话
|
||||
if (convId && !state.activeConversationId) {
|
||||
@@ -472,8 +589,9 @@ export function handleEvent(event: AiChatEvent) {
|
||||
state.generatingConvs.add(convId)
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
|
||||
if (!NO_RESET_WATCHDOG.has(event.type)) {
|
||||
resetStreamWatchdog()
|
||||
resetStreamWatchdog(convId || undefined)
|
||||
}
|
||||
// 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作(保留原 switch 无 default 的语义)
|
||||
if (handleStreamingEvent(event)) return
|
||||
@@ -526,7 +644,7 @@ function stopListener() {
|
||||
// _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会
|
||||
// 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。
|
||||
_startPromise = null
|
||||
clearStreamWatchdog()
|
||||
clearAllStreamWatchdogs() // TD-260621-01: 卸载清全部 per-conv + legacy fallback timer,防回调改已卸载组件 state
|
||||
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
|
||||
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { watch } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
@@ -132,7 +133,7 @@ async function clearChat() {
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
setStreaming(false, { reason: 'clearChat' })
|
||||
}
|
||||
|
||||
export function useAiPanel() {
|
||||
|
||||
@@ -19,9 +19,10 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import type { ContentPart, AiMessage } from '@/api/types'
|
||||
import type { ContentPart, AiMessage, MentionSpan } from '@/api/types'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared'
|
||||
|
||||
@@ -64,8 +65,12 @@ const modelOverride = ref<string | null>(null)
|
||||
* 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 doSend 仅把 parts 写入本地 push 的
|
||||
* user 消息让用户气泡可见图,后端请求仍走 content 文本路径(无图)。
|
||||
* Phase 2c 后端接 parts 后,本函数透传给 aiApi.sendMessage/forceSend 即可全链路生效。
|
||||
*
|
||||
* Input Augmentation: spans 透传给后端 ai_chat_send 的 mention_spans 参数(后端 resolve
|
||||
* 投影成 Augmentation 注入)+ 写入本地 push 的 user 消息(MessageList 据此渲染 chip)。
|
||||
* undefined/空数组 → 本地 user 消息 mentionSpans 置 undefined(纯文本气泡零回归)+ 后端不传。
|
||||
*/
|
||||
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[]) {
|
||||
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
||||
const userMsgId = `user-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: userMsgId,
|
||||
@@ -73,6 +78,8 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
content: text.trim(),
|
||||
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
||||
parts: parts && parts.length > 0 ? parts : undefined,
|
||||
// Input Augmentation: 仅在非空时挂 mentionSpans(供 MessageList chip 渲染;空则 undefined 零回归)
|
||||
mentionSpans: spans && spans.length > 0 ? spans : undefined,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
@@ -84,7 +91,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' })
|
||||
state.currentText = ''
|
||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
||||
|
||||
@@ -97,14 +104,15 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
if (force) {
|
||||
// F-05 Phase 2c: 透传 parts(图片)给后端 ai_chat_force_send,多模态全链生效
|
||||
// F-260616-09 B 批4(决策 e):传 activeConversationId,操作指定 conv 的 per_conv。
|
||||
await aiApi.forceSend(text.trim(), lang, skill, override, parts, state.activeConversationId)
|
||||
// Input Augmentation: 透传 mentionSpans(spans 非空时后端 resolve_and_inject 注入)。
|
||||
await aiApi.forceSend(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
|
||||
} else {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, state.activeConversationId)
|
||||
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
|
||||
}
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'doSend-ipc-fail' })
|
||||
clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
throw e
|
||||
@@ -139,7 +147,7 @@ async function regenerate() {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
|
||||
state.currentText = ''
|
||||
state.queue = [] // 重生成视同新发,清残留队列
|
||||
resetStreamWatchdog()
|
||||
@@ -149,7 +157,7 @@ async function regenerate() {
|
||||
const convId = state.activeConversationId
|
||||
if (!convId) {
|
||||
// 无活跃对话:回滚占位,报错
|
||||
state.streaming = false
|
||||
setStreaming(false, { reason: 'regenerate-no-conv' })
|
||||
clearStreamWatchdog()
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
throw new Error(t('aiChat.regenerateUnavailable'))
|
||||
@@ -158,7 +166,7 @@ async function regenerate() {
|
||||
await aiApi.regenerate(convId, lang, modelOverride.value)
|
||||
} catch (e) {
|
||||
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId, reason: 'regenerate-ipc-fail' })
|
||||
clearStreamWatchdog()
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
throw e
|
||||
@@ -209,7 +217,7 @@ async function editMessage(newMessage: string) {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
|
||||
state.currentText = ''
|
||||
state.queue = [] // 编辑重生成视同新发,清残留队列
|
||||
resetStreamWatchdog()
|
||||
@@ -218,7 +226,7 @@ async function editMessage(newMessage: string) {
|
||||
const lang = resolveAiLang()
|
||||
const convId = state.activeConversationId
|
||||
if (!convId) {
|
||||
state.streaming = false
|
||||
setStreaming(false, { reason: 'editMessage-no-conv' })
|
||||
clearStreamWatchdog()
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
throw new Error(t('aiChat.editNotLastUser'))
|
||||
@@ -227,7 +235,7 @@ async function editMessage(newMessage: string) {
|
||||
await aiApi.editMessage(convId, newMessage.trim(), lang, modelOverride.value)
|
||||
} catch (e) {
|
||||
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId, reason: 'editMessage-ipc-fail' })
|
||||
clearStreamWatchdog()
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
throw e
|
||||
@@ -250,7 +258,7 @@ export function drainQueue() {
|
||||
try {
|
||||
await sendMessage(next.text, next.skill, false, next.parts)
|
||||
} catch (e) {
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'drainQueue-fail' })
|
||||
clearStreamWatchdog()
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
|
||||
@@ -275,13 +283,18 @@ export function drainQueue() {
|
||||
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
|
||||
*
|
||||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||
*
|
||||
* Input Augmentation: spans 随消息透传(L0 直接 doSend / L2 force doSend);
|
||||
* L1 入队时 queue item 当前不挂 spans(队列续发属异步重试,mention 区间在首次发送时
|
||||
* 已与文本对齐,排队等待后用户可能改输入,续发用旧 spans 语义模糊;续发走无 mention 路径,
|
||||
* 与现有 parts 入队续发的设计取舍一致——队列为韧性保内容,非保全部上下文元数据)。
|
||||
*/
|
||||
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[]) {
|
||||
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
||||
if (!text.trim() && !(parts && parts.length)) return
|
||||
|
||||
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
||||
if (forceMode) {
|
||||
await doSend(text, skill, true, parts)
|
||||
await doSend(text, skill, true, parts, spans)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,7 +312,7 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
state.streaming = true
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
|
||||
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue 续发时本地 user 消息渲染图)
|
||||
state.queue.push({
|
||||
text: text.trim(),
|
||||
@@ -311,7 +324,7 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
||||
}
|
||||
|
||||
// ── L0:normal 直接发送 ──
|
||||
await doSend(text, skill, false, parts)
|
||||
await doSend(text, skill, false, parts, spans)
|
||||
}
|
||||
|
||||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
|
||||
@@ -349,7 +362,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
|
||||
enqueuedAt: first.enqueuedAt,
|
||||
parts: first.parts,
|
||||
})
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'tryForceSend-fail' })
|
||||
clearStreamWatchdog()
|
||||
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
|
||||
return false
|
||||
@@ -391,7 +404,7 @@ function editQueued(index: number, newText: string) {
|
||||
* 否则 stopChat→AiCompleted→drainQueue 续发时该项还在队列致重复发。
|
||||
* 2. 调 stopChat() 打断当前生成(stopChat UX-05 后已不清队列,故 splice 步骤前置必要)。
|
||||
* 3. 直接 sendMessage(splicedItem.text, skill) 立即发这条(不等 drainQueue 轮到)。
|
||||
* 当前未完成回复已由 stop 截断保留(agentic.rs:185 save_conversation),无需额外处理。
|
||||
* 当前未完成回复已由 stop 截断保留(agentic/mod.rs save_conversation),无需额外处理。
|
||||
*/
|
||||
async function sendQueuedNow(index: number) {
|
||||
if (index < 0 || index >= state.queue.length) return
|
||||
@@ -399,9 +412,9 @@ async function sendQueuedNow(index: number) {
|
||||
await stopChat()
|
||||
// B-260617-01: forceMode=true 跳过 L1 预检(ai_is_generating)直走 force_send。
|
||||
// stopChat() 仅 await stop IPC 发出(置 stop_flag=true),不等后端 loop 跑到
|
||||
// agentic.rs:444/:819 检测点+guard.reset()(generating 才复位)。紧接 sendMessage
|
||||
// agentic/mod.rs guard 检测点+guard.reset()(generating 才复位)。紧接 sendMessage
|
||||
// 命中 L1 的 backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,
|
||||
// 与"立即发送"语义不符。force_send(commands.rs:742-748)原子复位 generating=false
|
||||
// 与"立即发送"语义不符。force_send(commands/chat.rs ai_chat_force_send)原子复位 generating=false
|
||||
// 再发,无竞态窗口;stopChat 的 stop_flag 让旧 loop 在下个检测点退出,不冲突。
|
||||
await sendMessage(spliced.text, spliced.skill, true, spliced.parts)
|
||||
}
|
||||
@@ -417,11 +430,11 @@ async function sendQueuedNow(index: number) {
|
||||
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
|
||||
*/
|
||||
async function stopChat() {
|
||||
state.streaming = false // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
|
||||
clearStreamWatchdog() // 清残留看门狗
|
||||
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
|
||||
// 后端 agentic.rs:190 emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
|
||||
// 当前未完成回复已入库(agentic.rs:185 save_conversation)保留为截断回复。
|
||||
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
|
||||
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
|
||||
try {
|
||||
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
|
||||
await aiApi.stopChat(state.activeConversationId)
|
||||
|
||||
@@ -14,23 +14,43 @@
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { forceResetStreaming } from './streamingGuard'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, ai.rs:848)+余量;
|
||||
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, stream_recv.rs STREAM_IDLE_TIMEOUT)+余量;
|
||||
/// 前端若短于后端,慢首 token 会被前端先误杀
|
||||
export const STREAM_TIMEOUT_MS = 130000
|
||||
|
||||
let _streamWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
// TD-260621-01 per-conv 看门狗:模块级单计时器 → convId → timer Map。
|
||||
//
|
||||
// 背景(F-09 多会话并发):原单 timer 下,A 会话 delta 重置计时器会顶掉 B 会话已挂起的 timer,
|
||||
// 或 A 超时连累 B 的 generatingConvs(全清)。per-conv Map 后,各会话独立计时,互不顶替/连累。
|
||||
//
|
||||
// 向后兼容(避撞 F-09 禁动域):convId 可选。禁动域 useAiSend.ts/useAiApproval.ts 的 14 处调用
|
||||
// 不传 convId,走 _legacyWatchdog fallback(模块级单 timer,行为对齐旧版);本批域 useAiEvents.ts
|
||||
// 的调用点(已知 convId)传 convId 走 per-conv Map。两路并存,等 F-09 统一迁移调用点后可删 fallback。
|
||||
//
|
||||
// per-conv 路径与 legacy 路径互不干扰:convId 路径只动 Map,legacy 路径只动 _legacyWatchdog。
|
||||
// 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent:546 isCurrent 判定),
|
||||
// 非 current conv 的事件不触发 reset/clear(reset/clear 在 isCurrent 分支内调用),故 per-conv
|
||||
// 路径天然只跟踪当前展示会话的活跃 timer,跨会话切换时旧 conv 的 timer 由其 AiCompleted/AiError 精确清。
|
||||
const _watchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// 禁动域 fallback 单 timer(useAiSend/useAiApproval 无 convId 调用走此,行为对齐旧版)。
|
||||
let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 看门狗超时回调:收尾 streaming 态并补错误消息 */
|
||||
export function onStreamTimeout() {
|
||||
state.streaming = false
|
||||
// F-09: 多会话并发 — 超时收尾清空全部生成态(整流超时是兜底场景,无法确定具体 conv,
|
||||
// 保守全清避免幽灵;正常路径由各 conv 的 AiCompleted/AiError 精确 remove)
|
||||
state.generatingConvs.clear()
|
||||
state.currentText = ''
|
||||
state.queue = [] // B-32:超时收尾同步清队列,防生成中入队的消息静默丢失
|
||||
clearStreamWatchdog()
|
||||
/**
|
||||
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位,
|
||||
* 对齐 [[devflow-generating-statemachine]])。
|
||||
*
|
||||
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的
|
||||
* generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空
|
||||
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
clearStreamWatchdog(convId)
|
||||
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
|
||||
// 本模块不 import useAiSend(会构成 useAiSend↔useAiStream 循环依赖,与现有破环原则冲突),
|
||||
// 复用 ai-tool-slow-toast 同款事件总线模式:emit 由 useAiEvents.startListener listen 后调
|
||||
@@ -44,16 +64,21 @@ export function onStreamTimeout() {
|
||||
// 2) 探测任一 completed 卡片以区分错误文案(工具已执行完后续回复中断 vs 纯流式中断)。
|
||||
// 反向扫尾部提前退出(同 findToolCall 策略);不复用 useAiEvents.findToolCall:本模块
|
||||
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。
|
||||
// 注:messages 遍历仍是单例(currentText 一样),per-conv 超时只切到当前 messages 视图;
|
||||
// 多会话并发下其他会话的 messages 不在此 state.messages(切走即整体替换),无跨会话误回滚。
|
||||
let hasCompletedTool = false
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
const tc = toolCalls[j]
|
||||
if (tc.status === 'running') {
|
||||
// B-33:running 态无后续事件回流,置 rejected 释放骨架屏,恢复重审入口
|
||||
tc.status = 'rejected'
|
||||
} else if (tc.status === 'completed') {
|
||||
// TD-260621-01 修复(用户实测卡死根因):不再把 running→rejected。
|
||||
// path_auth/risk 挂起的 toolCall 停在 running(path_auth 不置 pending_approval),
|
||||
// 看门狗超时自动拒绝会误杀用户未操作的审批(用户报"显示用户拒绝了此操作,但根本没操作")。
|
||||
// 看门狗只做流断兜底(清 streaming/currentText/queue),工具/审批态由各自生命周期
|
||||
// (AiToolCallCompleted/AiApprovalResult/ai_authorize_dir)管理。running 骨架屏由
|
||||
// ToolCard 本地 approving 计时器兜底,或 path_auth 授权后 AiToolCallCompleted 转 completed。
|
||||
if (tc.status === 'completed') {
|
||||
hasCompletedTool = true
|
||||
}
|
||||
}
|
||||
@@ -70,15 +95,48 @@ export function onStreamTimeout() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动/重置看门狗(活跃事件或 sendMessage 时调用) */
|
||||
export function resetStreamWatchdog() {
|
||||
if (_streamWatchdog) clearTimeout(_streamWatchdog)
|
||||
_streamWatchdog = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
||||
/**
|
||||
* 启动/重置看门狗(活跃事件或 sendMessage 时调用)。
|
||||
*
|
||||
* TD-260621-01 per-conv:传 convId → 走 per-conv Map(arm 前 clear 该 conv 旧 timer,防重入堆积);
|
||||
* 省略 convId(禁动域 useAiSend/useAiApproval 调用)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。
|
||||
* setTimeout 回调闭包捕获 convId,到期 onStreamTimeout(convId) 精确清该 conv 态。
|
||||
*/
|
||||
export function resetStreamWatchdog(convId?: string) {
|
||||
if (convId) {
|
||||
const old = _watchdogTimers.get(convId)
|
||||
if (old) clearTimeout(old)
|
||||
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
|
||||
_watchdogTimers.set(convId, timer)
|
||||
} else {
|
||||
// 禁动域 fallback:行为对齐旧版单 timer。
|
||||
if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
|
||||
_legacyWatchdog = setTimeout(() => onStreamTimeout(), STREAM_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除看门狗(完成/错误/审批等待时调用) */
|
||||
export function clearStreamWatchdog() {
|
||||
if (_streamWatchdog) { clearTimeout(_streamWatchdog); _streamWatchdog = null }
|
||||
/**
|
||||
* 清除看门狗(完成/错误/审批等待时调用)。
|
||||
*
|
||||
* TD-260621-01 per-conv:传 convId → 仅清该 conv 的 timer;省略 → 清 _legacyWatchdog fallback。
|
||||
* 注:省略 convId 时**不**清 Map(避免禁动域全局 clear 误清并发会话的 per-conv timer)。
|
||||
* stopListener 卸载场景需清全部,调 clearAllStreamWatchdogs()。
|
||||
*/
|
||||
export function clearStreamWatchdog(convId?: string) {
|
||||
if (convId) {
|
||||
const timer = _watchdogTimers.get(convId)
|
||||
if (timer) { clearTimeout(timer); _watchdogTimers.delete(convId) }
|
||||
} else {
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除全部看门狗 timer(per-conv Map + legacy fallback)—— stopListener 卸载场景调用,
|
||||
* 防回调写已卸载组件的 state。 */
|
||||
export function clearAllStreamWatchdogs() {
|
||||
for (const timer of _watchdogTimers.values()) clearTimeout(timer)
|
||||
_watchdogTimers.clear()
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
}
|
||||
|
||||
export function useAiStream() {
|
||||
@@ -86,5 +144,6 @@ export function useAiStream() {
|
||||
onStreamTimeout,
|
||||
resetStreamWatchdog,
|
||||
clearStreamWatchdog,
|
||||
clearAllStreamWatchdogs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { state } from '@/stores/ai'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
|
||||
// ── 主窗口事件跟随 unlistener ──
|
||||
@@ -61,7 +62,7 @@ async function detachPanel(convId?: string) {
|
||||
// F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
|
||||
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
|
||||
state.streaming = false
|
||||
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
|
||||
if (targetConv) state.generatingConvs.delete(targetConv)
|
||||
const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
@@ -192,7 +193,7 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
state.streaming = true
|
||||
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
|
||||
state.generatingConvs.add(gen)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -85,11 +85,25 @@ export interface ToolResult {
|
||||
body?: string
|
||||
body_bytes?: number
|
||||
elapsed_ms?: number
|
||||
// grep 跨文件内容搜索结果字段(F-260621):
|
||||
// - output_mode: content(默认)/ files_with_matches / count
|
||||
// - matches: content 模式命中行 [{file,line,content,context?}]
|
||||
// - files: files_with_matches 模式命中文件名数组
|
||||
// - counts: count 模式每文件命中行数 [{file,count}]
|
||||
// - total: 命中总数(content 模式=命中行数,count 模式=命中行数,files_with_matches 模式=命中文件数)
|
||||
// - total_files: count 模式命中文件数
|
||||
output_mode?: string
|
||||
matches?: Array<{ file?: string; line?: number | null; content?: string; context?: string }>
|
||||
files?: string[]
|
||||
counts?: Array<{ file?: string; count?: number }>
|
||||
total_files?: number
|
||||
}
|
||||
|
||||
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
|
||||
export const dirIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
|
||||
export const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
|
||||
/** grep 工具放大镜图标(跨文件内容搜索,F-260621) */
|
||||
export const searchIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
||||
|
||||
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
|
||||
export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
||||
@@ -223,6 +237,7 @@ export function toolCategory(name: string): string {
|
||||
export function toolIcon(name: string): string {
|
||||
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
|
||||
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
|
||||
if (name === 'grep') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
||||
if (name === 'list_directory') return dirIcon
|
||||
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
|
||||
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
|
||||
@@ -405,6 +420,31 @@ function formatSearchFiles(r: ToolResult): string {
|
||||
return t('aiTool.foundN', { n })
|
||||
}
|
||||
|
||||
/**
|
||||
* grep 摘要/命中计数标签(F-260621):按 output_mode 取合适文案,三分支:
|
||||
* - content(默认):「命中 N 处」(total = 命中行数,total 缺省回退 matches.length)
|
||||
* - files_with_matches:「命中 N 文件」(files.length)
|
||||
* - count:「命中 N 处 / M 文件」(total 行数 + total_files)
|
||||
*
|
||||
* 抽公共单一函数 G-1:原 ToolResultBody.vue grepHitLabel 与此处 formatGrep 三分支逐行等价,
|
||||
* 现 export 此函数,ToolResultBody.vue 复用,消除文案漂移风险。i18n 走 @/i18n/i18n-helpers 类型安全 t。
|
||||
*/
|
||||
export function formatGrep(r: ToolResult): string {
|
||||
const mode = r.output_mode
|
||||
if (mode === 'files_with_matches') {
|
||||
const n = Array.isArray(r.files) ? r.files.length : 0
|
||||
return t('aiTool.grepFilesN', { n })
|
||||
}
|
||||
if (mode === 'count') {
|
||||
const total = typeof r.total === 'number' ? r.total : 0
|
||||
const files = typeof r.total_files === 'number' ? r.total_files : 0
|
||||
return t('aiTool.grepCountSummary', { n: total, files })
|
||||
}
|
||||
// content(默认)
|
||||
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.matches) ? r.matches.length : 0)
|
||||
return t('aiTool.grepHitsN', { n })
|
||||
}
|
||||
|
||||
/**
|
||||
* rename_file 格式化(body 版): renamed===false 走 formatJson 兜底(summary 版走空串)。
|
||||
* 故 body 与 summary 分别登记不同 formatter。
|
||||
@@ -454,6 +494,7 @@ const TOOL_FORMATTERS: Record<string, (r: ToolResult) => string> = {
|
||||
bind_directory: formatBindDir,
|
||||
run_workflow: formatRunWorkflow,
|
||||
http_request: formatHttpResult,
|
||||
grep: formatGrep,
|
||||
}
|
||||
|
||||
export function formatToolResult(tc: AiToolCallInfo): string {
|
||||
@@ -611,6 +652,7 @@ const TOOL_SUMMARIES: Record<string, (r: ToolResult) => string> = {
|
||||
bind_directory: summaryBindDir,
|
||||
run_workflow: summaryRunWorkflow,
|
||||
http_request: summaryHttpRequest,
|
||||
grep: formatGrep,
|
||||
}
|
||||
|
||||
export function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
|
||||
@@ -101,6 +101,12 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
|
||||
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
|
||||
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
|
||||
case 'grep': {
|
||||
// grep 头部:搜索根 + pattern(折叠态可见搜索什么)
|
||||
const pat = argString(tc.args, 'pattern')
|
||||
const head = p ? `${t('aiTool.grepPrefix')} ${shortPath(p)}` : t('aiTool.grepFallback')
|
||||
return pat ? `${head} 「${pat}」` : head
|
||||
}
|
||||
case 'delete_project':
|
||||
case 'restore_project':
|
||||
case 'purge_project':
|
||||
|
||||
@@ -153,6 +153,16 @@ export default {
|
||||
dirAuthDeny: 'Deny',
|
||||
dirAuthFailed: 'Authorization failed: {msg}',
|
||||
|
||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||
searchFilesBlocked: 'search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.',
|
||||
// /skill hot reload (ai_reload_skills IPC) success toast
|
||||
skillReloaded: 'Skills reloaded',
|
||||
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
||||
mentionUnresolved: 'The mentioned {kind} was deleted or is unavailable',
|
||||
// Same-name skill conflict (scan_skills returns conflicts; frontend warns, injection takes first by skills>commands>plugins priority)
|
||||
skillConflict: 'Skill "{name}" has multiple definitions; using skills priority',
|
||||
|
||||
|
||||
// ── F-15 phase 2: manual context management (clear / compress) ──
|
||||
// Buttons (Header action area: trash + compress icon)
|
||||
|
||||
@@ -29,6 +29,9 @@ export default {
|
||||
dirFallback: 'List Directory',
|
||||
writePrefix: 'Write',
|
||||
writeFallback: 'Write File',
|
||||
// grep cross-file content search header (F-260621)
|
||||
grepPrefix: 'Search',
|
||||
grepFallback: 'Content Search',
|
||||
// CRUD tool display names (delete/restore/purge/update_project resolve project name by id; fallback when no id or unresolved)
|
||||
deletePrefix: 'Delete Project',
|
||||
deleteFallback: 'Delete Project',
|
||||
@@ -88,6 +91,12 @@ export default {
|
||||
httpTruncated: 'Response truncated ({bytes}), see details',
|
||||
httpRequestFallback: 'HTTP Request',
|
||||
foundN: 'Found {n}',
|
||||
// grep cross-file content search results (F-260621): per output_mode phrasing
|
||||
grepHitsN: '{n} hits',
|
||||
grepFilesN: '{n} files',
|
||||
grepCountSummary: '{n} hits / {files} files',
|
||||
grepCountLines: '{n} hits',
|
||||
grepTruncated: 'Results truncated (max reached), narrow pattern or add glob',
|
||||
boundDir: 'Bound directory "{path}" stack: {stack}',
|
||||
deleteFilePermanent: 'Permanently deleted {path}',
|
||||
// UX-260618-07: soft-delete adds backup_path (trash filename so user knows where to recover from)
|
||||
@@ -119,6 +128,7 @@ export default {
|
||||
readFile: 'Read File',
|
||||
writeFile: 'Write File',
|
||||
listDirectory: 'List Directory',
|
||||
grep: 'Content Search',
|
||||
createTask: 'Create Task',
|
||||
createProject: 'Create Project',
|
||||
createIdea: 'Create Idea',
|
||||
|
||||
@@ -37,6 +37,18 @@ export default {
|
||||
actionTitle: '💡 Action Items',
|
||||
evaluating: '⏳ Evaluating…',
|
||||
startEval: '🔍 Start Adversarial Eval',
|
||||
retryEval: 'Retry',
|
||||
evalModeLlm: 'LLM deep eval',
|
||||
evalModeFallback: 'Heuristic fallback',
|
||||
|
||||
// Description editing
|
||||
editDesc: 'Edit',
|
||||
saveDesc: 'Save',
|
||||
cancelEdit: 'Cancel',
|
||||
|
||||
// List sorting
|
||||
sortByScore: 'By score',
|
||||
sortByTime: 'By time',
|
||||
|
||||
// Assessment badges
|
||||
assessment: {
|
||||
@@ -48,10 +60,35 @@ export default {
|
||||
cancel: '❌ Cancel idea',
|
||||
},
|
||||
|
||||
// Project detail "Source Idea" card (promote carries evaluation conclusion back)
|
||||
sourceIdea: '💡 Source Idea',
|
||||
sourceIdeaNotEvaluated: 'Idea not evaluated yet, click to view',
|
||||
|
||||
// Multi-dimensional score
|
||||
multiScoreTitle: '📊 Multi-dim Score',
|
||||
noEval: 'No evaluation yet',
|
||||
|
||||
// Evaluation history (version timeline)
|
||||
historyTitle: '📜 Evaluation History',
|
||||
historyVersion: 'Version',
|
||||
historyLatest: 'Latest',
|
||||
historyEmpty: 'No evaluation history',
|
||||
historyLoading: 'Loading history…',
|
||||
|
||||
// Related ideas
|
||||
relatedTitle: '🔗 Related Ideas',
|
||||
relatedEmpty: 'No relations',
|
||||
relatedManage: 'Manage relations',
|
||||
relatedConfirm: 'Confirm',
|
||||
relatedCancel: 'Cancel',
|
||||
|
||||
// Idea stats panel (used by IdeasPanel)
|
||||
statsTitle: '📈 Overview',
|
||||
statsTotal: 'Total',
|
||||
statsPending: 'Pending',
|
||||
statsPromoted: 'Promoted',
|
||||
statsAvgScore: 'Avg score',
|
||||
|
||||
// Tags
|
||||
tagsTitle: '🏷️ Tags',
|
||||
noTags: 'No tags yet',
|
||||
@@ -69,6 +106,7 @@ export default {
|
||||
// Action buttons
|
||||
promoteToProject: '🚀 Promote to Project',
|
||||
deleteIdea: '🗑️ Delete Idea',
|
||||
promotedProject: 'Promoted to project',
|
||||
|
||||
// Capture modal
|
||||
captureTitle: '✨ Capture New Idea',
|
||||
|
||||
@@ -154,6 +154,16 @@ export default {
|
||||
dirAuthDeny: '拒绝',
|
||||
dirAuthFailed: '授权失败:{msg}',
|
||||
|
||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||
searchFilesBlocked: 'search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径',
|
||||
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
||||
skillReloaded: '技能已重新加载',
|
||||
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
||||
mentionUnresolved: '引用的 {kind} 已删除或不可用',
|
||||
// 同名技能冲突(scan_skills 返回 conflicts,前端提示,注入按 skills>commands>plugins 优先级取首份)
|
||||
skillConflict: '技能 {name} 存在多份定义,已采用 skills 优先级',
|
||||
|
||||
|
||||
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
||||
// 按钮(Header 操作区:垃圾桶 + 压缩图标)
|
||||
|
||||
@@ -29,6 +29,9 @@ export default {
|
||||
dirFallback: '列出目录',
|
||||
writePrefix: '写入',
|
||||
writeFallback: '写入文件',
|
||||
// grep 跨文件内容搜索头部(F-260621)
|
||||
grepPrefix: '搜索',
|
||||
grepFallback: '内容搜索',
|
||||
// CRUD 类工具显示名(delete/restore/purge/update_project 按 id 解析项目名;无 id 或解析失败走 fallback)
|
||||
deletePrefix: '删除项目',
|
||||
deleteFallback: '删除项目',
|
||||
@@ -89,6 +92,12 @@ export default {
|
||||
httpTruncated: '响应已截断({bytes}),详见下方',
|
||||
httpRequestFallback: 'HTTP 请求',
|
||||
foundN: '找到 {n} 项',
|
||||
// grep 跨文件内容搜索结果(F-260621):按 output_mode 分文案
|
||||
grepHitsN: '命中 {n} 处',
|
||||
grepFilesN: '命中 {n} 文件',
|
||||
grepCountSummary: '命中 {n} 处 / {files} 文件',
|
||||
grepCountLines: '{n} 处',
|
||||
grepTruncated: '结果已截断(达上限),收紧 pattern 或加 glob',
|
||||
boundDir: '已绑定目录「{path}」技术栈:{stack}',
|
||||
deleteFilePermanent: '已硬删除 {path}',
|
||||
// UX-260618-07:软删补 backup_path(回收站文件名,用户知从哪恢复)
|
||||
@@ -123,6 +132,7 @@ export default {
|
||||
readFile: '读取文件',
|
||||
writeFile: '写入文件',
|
||||
listDirectory: '列出目录',
|
||||
grep: '内容搜索',
|
||||
createTask: '创建任务',
|
||||
createProject: '创建项目',
|
||||
createIdea: '创建灵感',
|
||||
|
||||
@@ -37,6 +37,9 @@ export default {
|
||||
actionTitle: '💡 行动建议',
|
||||
evaluating: '⏳ 评估中…',
|
||||
startEval: '🔍 开始对抗评估',
|
||||
retryEval: '重试',
|
||||
evalModeLlm: 'LLM 深度评估',
|
||||
evalModeFallback: '启发式降级',
|
||||
|
||||
// 评估结论徽章
|
||||
assessment: {
|
||||
@@ -48,10 +51,35 @@ export default {
|
||||
cancel: '❌ 取消灵感',
|
||||
},
|
||||
|
||||
// 项目详情页「来源灵感」卡片(晋升携带评估结论回溯)
|
||||
sourceIdea: '💡 来源灵感',
|
||||
sourceIdeaNotEvaluated: '该灵感尚未评估,点击查看',
|
||||
|
||||
// 多维评分
|
||||
multiScoreTitle: '📊 多维评分',
|
||||
noEval: '暂无评估',
|
||||
|
||||
// 评估历史(版本时间线)
|
||||
historyTitle: '📜 评估历史',
|
||||
historyVersion: '版本',
|
||||
historyLatest: '最新',
|
||||
historyEmpty: '暂无历史评估',
|
||||
historyLoading: '加载历史中…',
|
||||
|
||||
// 关联灵感
|
||||
relatedTitle: '🔗 关联灵感',
|
||||
relatedEmpty: '暂无关联',
|
||||
relatedManage: '管理关联',
|
||||
relatedConfirm: '确定',
|
||||
relatedCancel: '取消',
|
||||
|
||||
// 灵感统计看板(IdeasPanel 引用)
|
||||
statsTitle: '📈 灵感概览',
|
||||
statsTotal: '总数',
|
||||
statsPending: '待评估',
|
||||
statsPromoted: '已立项',
|
||||
statsAvgScore: '平均分',
|
||||
|
||||
// 标签
|
||||
tagsTitle: '🏷️ 标签',
|
||||
noTags: '暂无标签',
|
||||
@@ -69,6 +97,16 @@ export default {
|
||||
// 操作按钮
|
||||
promoteToProject: '🚀 立项为项目',
|
||||
deleteIdea: '🗑️ 删除灵感',
|
||||
promotedProject: '已立项为项目',
|
||||
|
||||
// 描述编辑
|
||||
editDesc: '编辑',
|
||||
saveDesc: '保存',
|
||||
cancelEdit: '取消',
|
||||
|
||||
// 列表排序
|
||||
sortByScore: '按评分',
|
||||
sortByTime: '按时间',
|
||||
|
||||
// 捕捉模态框
|
||||
captureTitle: '✨ 捕捉新灵感',
|
||||
|
||||
67
src/utils/ideaEval.ts
Normal file
67
src/utils/ideaEval.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 灵感评估(assessment 三件套)共用纯函数 + 类型。
|
||||
*
|
||||
* 抽自 ProjectDetail.vue / IdeaDetail.vue 两处完全相同的定义
|
||||
* (ScoreDimension interface + parseScores + assessmentClass + assessmentLabel),
|
||||
* 消除 DRY 重复。本模块无响应式依赖,可被任意 <script setup> import 复用。
|
||||
*/
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
/** 传统评分维度:name + 分数 */
|
||||
export interface ScoreDimension {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 idea.scores(JSON 对象 {dim: score})为维度数组。
|
||||
* null / 空 / 非法 / 非对象 → []。非数值分维度记 0。
|
||||
* @param scoresJson idea.scores 原始字符串(可能为 null)
|
||||
*/
|
||||
export function parseScores(scoresJson: string | null | undefined): ScoreDimension[] {
|
||||
if (!scoresJson) return []
|
||||
try {
|
||||
const parsed = JSON.parse(scoresJson)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed).map(([name, score]) => ({
|
||||
name,
|
||||
score: typeof score === 'number' ? score : 0,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** recommendation → CSS badge 颜色类映射(对应 .immediate/.soon/.conditional/.revised/.defer/.cancel) */
|
||||
const ASSESSMENT_CLASS_MAP: Record<string, string> = {
|
||||
'immediate action': 'immediate',
|
||||
'soon': 'soon',
|
||||
'with resources': 'conditional',
|
||||
'research more': 'revised',
|
||||
'monitor': 'defer',
|
||||
'cancel': 'cancel',
|
||||
}
|
||||
|
||||
/**
|
||||
* recommendation 字符串 → CSS badge 类。
|
||||
* 未知值回退到 'conditional'(中性,对齐原两处实现)。
|
||||
*/
|
||||
export function assessmentClass(recommendation: string): string {
|
||||
return ASSESSMENT_CLASS_MAP[recommendation.toLowerCase()] ?? 'conditional'
|
||||
}
|
||||
|
||||
/**
|
||||
* recommendation 字符串 → i18n 本地化标签。
|
||||
* 命中 ideas.assessment.{recommendation} 则返回译文,否则回退到原始 recommendation。
|
||||
* @param t vue-i18n 的 t 函数(useI18n().t),由调用方注入避免本模块耦合 i18n 实例
|
||||
*/
|
||||
export function assessmentLabel(
|
||||
t: ComposerTranslation,
|
||||
recommendation: string,
|
||||
): string {
|
||||
const key = `ideas.assessment.${recommendation}`
|
||||
const translated = t(key)
|
||||
return translated === key ? recommendation : translated
|
||||
}
|
||||
@@ -44,8 +44,8 @@ export function formatDate(v: string | number | null | undefined): string {
|
||||
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */
|
||||
export function formatRelativeZh(v: string | number | null | undefined): string {
|
||||
/** 相对时间(走 i18n:justNow/minutesAgo/hoursAgo/dayAgo;en/zh 双语);无效返回 '—' */
|
||||
export function formatRelative(v: string | number | null | undefined): string {
|
||||
const ms = parseTs(v)
|
||||
if (ms == null) return '—'
|
||||
const diffMin = Math.floor((Date.now() - ms) / 60000)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<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-rel">{{ formatRelative(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>
|
||||
@@ -65,7 +65,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelativeZh, formatDate } from '@/utils/time'
|
||||
import { formatRelative, formatDate } from '@/utils/time'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
>
|
||||
{{ f.icon }} {{ $t(f.labelKey) }}
|
||||
</button>
|
||||
<!-- 排序切换:score 高分在前 / time 新在前 -->
|
||||
<div class="sort-group">
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: sortMode === 'score' }"
|
||||
@click="sortMode = 'score'"
|
||||
>{{ $t('ideas.sortByScore') }}</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: sortMode === 'time' }"
|
||||
@click="sortMode = 'time'"
|
||||
>{{ $t('ideas.sortByTime') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 两栏布局 -->
|
||||
@@ -72,6 +85,8 @@
|
||||
@promote="promoteToProject"
|
||||
@delete="deleteCurrentIdea"
|
||||
@status-change="onStatusChange"
|
||||
@update-desc="onUpdateDesc"
|
||||
@update-related="onUpdateRelated"
|
||||
/>
|
||||
|
||||
<!-- 未选择 -->
|
||||
@@ -132,6 +147,8 @@ type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
const selectedId = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
|
||||
const sortMode = ref<'score' | 'time'>('score')
|
||||
|
||||
// ── 新建灵感模态框 ──
|
||||
const showCaptureModal = ref(false)
|
||||
@@ -185,7 +202,17 @@ const filteredIdeas = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
return ideas
|
||||
// 排序:在过滤+搜索之后、return 之前对最终数组排序
|
||||
// [...ideas] 浅拷贝避免改原数组(store.ideas)
|
||||
const sorted = [...ideas].sort((a, b) => {
|
||||
if (sortMode.value === 'score') {
|
||||
return (b.score ?? 0) - (a.score ?? 0) // 高分在前
|
||||
}
|
||||
// time:created_at 为毫秒字符串,字符串比较即可(同位数字典序等价数值序)
|
||||
return (b.created_at ?? '').localeCompare(a.created_at ?? '') // 新在前
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
const currentIdea = computed(() => {
|
||||
@@ -278,6 +305,18 @@ async function onStatusChange(newStatus: IdeaStatus) {
|
||||
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-desc'(描述编辑保存)
|
||||
async function onUpdateDesc(desc: string) {
|
||||
if (!currentIdea.value) return
|
||||
await store.updateIdea(currentIdea.value.id, 'description', desc)
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存)
|
||||
async function onUpdateRelated(ids: string[]) {
|
||||
if (!currentIdea.value) return
|
||||
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadIdeas()
|
||||
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36)
|
||||
@@ -376,6 +415,13 @@ watch(() => route.params.id, (id) => {
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* 排序按钮组(与状态筛选按钮视觉分隔) */
|
||||
.sort-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ===== 两栏布局 ===== */
|
||||
.ideas-layout {
|
||||
display: grid;
|
||||
|
||||
@@ -66,14 +66,6 @@
|
||||
<h2>{{ $t('projectDetail.infoTitle') }}</h2>
|
||||
</div>
|
||||
<div class="project-info" v-if="currentProject">
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('projectDetail.sourceIdea') }}</span>
|
||||
<router-link v-if="currentProject.idea_id" :to="`/ideas/${currentProject.idea_id}`" class="idea-link">
|
||||
{{ $t('projectDetail.viewIdea') }}
|
||||
</router-link>
|
||||
<span v-else class="no-idea">{{ $t('projectDetail.ideaDeleted') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 代码目录(绑定 + 重定位) -->
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('projectDetail.codeDirLabel') }}</span>
|
||||
@@ -124,6 +116,65 @@
|
||||
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
|
||||
<span class="status-badge" :class="currentProject.status">{{ $t(statusLabel) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 来源灵感卡片(晋升携带评估结论回溯):仅 idea_id 有值时显示 -->
|
||||
<div v-if="currentProject.idea_id" class="source-idea-card">
|
||||
<div class="source-idea-header">
|
||||
<span class="source-idea-title">{{ $t('ideas.sourceIdea') }}</span>
|
||||
<router-link
|
||||
v-if="sourceIdea"
|
||||
class="idea-link"
|
||||
:to="`/ideas/${currentProject.idea_id}`"
|
||||
>
|
||||
{{ sourceIdea.title }} →
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
class="idea-link idea-link-dim"
|
||||
:to="`/ideas/${currentProject.idea_id}`"
|
||||
>
|
||||
#{{ currentProject.idea_id }} →
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- sourceIdea 未找到(灵感被删/未 load):提示跳转 -->
|
||||
<p v-if="!sourceIdea" class="source-idea-hint">{{ $t('projectDetail.ideaDeleted') }}</p>
|
||||
<!-- 已评估:显示评估结论回溯 -->
|
||||
<template v-else-if="sourceAiAnalysis">
|
||||
<div class="source-idea-body">
|
||||
<span
|
||||
class="assessment-badge"
|
||||
:class="assessmentClass(sourceAiAnalysis.recommendation)"
|
||||
>
|
||||
{{ assessmentLabel(sourceAiAnalysis.recommendation) }}
|
||||
</span>
|
||||
<span class="source-final-score">
|
||||
{{ $t('ideas.finalScore', { score: sourceAiAnalysis.final_score.toFixed(1) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="sourceAiAnalysis.summary" class="source-summary">{{ sourceAiAnalysis.summary }}</p>
|
||||
<!-- 多维评分条(借鉴 IdeaDetail parseScores 渲染) -->
|
||||
<div v-if="sourceScores.length" class="source-scores">
|
||||
<div class="radar-row" v-for="dim in sourceScores" :key="dim.name">
|
||||
<span class="radar-label">{{ dim.name }}</span>
|
||||
<div class="radar-bar-track">
|
||||
<div
|
||||
class="radar-bar-fill"
|
||||
:class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"
|
||||
:style="{ width: dim.score + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="radar-value">{{ dim.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 灵感未评估:鼓励去评估 -->
|
||||
<p v-else class="source-idea-hint">
|
||||
<router-link class="idea-link" :to="`/ideas/${currentProject.idea_id}`">
|
||||
{{ $t('ideas.sourceIdeaNotEvaluated') }} →
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -195,6 +246,7 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { projectApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { parseStack } from '@/utils/project'
|
||||
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n } from '@/utils/ideaEval'
|
||||
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
||||
@@ -237,6 +289,48 @@ const stageKey = computed(() => currentProject.value?.status ?? 'planning')
|
||||
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
|
||||
const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
|
||||
|
||||
// ── 来源灵感(晋升携带评估结论回溯)──
|
||||
// ProjectRecord.idea_id 存灵感 id(promote_idea 写入)。
|
||||
// sourceIdea: 从 store.ideas 反查;store 未 load 时由 onMounted 守卫补 load。
|
||||
const sourceIdea = computed(() =>
|
||||
store.ideas.find(i => i.id === currentProject.value?.idea_id) ?? null,
|
||||
)
|
||||
|
||||
// ai_analysis / scores 是 JSON 字符串(对齐 IdeaDetail 现有解析模式,try/catch 容错)
|
||||
interface SourceAnalysis {
|
||||
recommendation: string
|
||||
final_score: number
|
||||
summary: string
|
||||
}
|
||||
const sourceAiAnalysis = computed<SourceAnalysis | null>(() => {
|
||||
const idea = sourceIdea.value
|
||||
if (!idea?.ai_analysis) return null
|
||||
try {
|
||||
const parsed = JSON.parse(idea.ai_analysis) as Record<string, unknown>
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const recommendation = typeof parsed.recommendation === 'string' ? parsed.recommendation : ''
|
||||
const final_score = typeof parsed.final_score === 'number' ? parsed.final_score : 0
|
||||
// summary 优先顶层,回退 analyst.summary(对齐 IdeaDetail historySummary 模式)
|
||||
const summary = typeof parsed.summary === 'string' ? parsed.summary
|
||||
: (typeof parsed.analyst === 'object' && parsed.analyst !== null
|
||||
&& typeof (parsed.analyst as Record<string, unknown>).summary === 'string'
|
||||
? ((parsed.analyst as Record<string, unknown>).summary as string)
|
||||
: '')
|
||||
if (!recommendation && !final_score && !summary) return null
|
||||
return { recommendation, final_score, summary }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 项1 DRY:ScoreDimension/parseScores/assessmentClass/assessmentLabel 抽到 @/utils/ideaEval,
|
||||
// 与 IdeaDetail 共用。sourceScores 仅保留 computed(读 sourceIdea.scores → 复用纯函数解析)。
|
||||
const sourceScores = computed(() => parseScoresJson(sourceIdea.value?.scores))
|
||||
|
||||
// assessmentClass 直接复用 ideaEval(模板直调);assessmentLabel 需注入 t,留薄包装消除重复。
|
||||
const assessmentLabel = (recommendation: string): string =>
|
||||
assessmentLabelI18n(t, recommendation)
|
||||
|
||||
// ── 任务 ──
|
||||
const projectTasks = computed(() =>
|
||||
store.tasks.filter(t => t.project_id === projectId.value)
|
||||
@@ -410,6 +504,9 @@ onMounted(async () => {
|
||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||
await store.loadProjects()
|
||||
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
|
||||
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
|
||||
// 否则 sourceIdea computed 永远找不到对应灵感记录
|
||||
await store.loadIdeas()
|
||||
unlisten = await store.startEventListener()
|
||||
await checkPath()
|
||||
})
|
||||
@@ -477,6 +574,85 @@ onUnmounted(() => {
|
||||
|
||||
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
|
||||
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
|
||||
|
||||
/* ===== 来源灵感卡片(晋升携带评估结论回溯)===== */
|
||||
.source-idea-card {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.source-idea-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.source-idea-title { font-size: 13px; font-weight: 500; color: var(--df-text); }
|
||||
.info-item a.idea-link.idea-link-dim,
|
||||
.source-idea-card a.idea-link-dim { color: var(--df-text-dim); }
|
||||
.source-idea-card .source-idea-hint {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.source-idea-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.source-final-score {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.source-summary {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* assessment badge(复用 IdeaDetail 同名类的样式定义) */
|
||||
.assessment-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.assessment-badge.immediate { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.assessment-badge.soon { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.assessment-badge.conditional { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.assessment-badge.revised { background: rgba(255, 217, 61, 0.2); color: var(--df-warning); }
|
||||
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
/* 多维评分条(复用 IdeaDetail radar-* 样式定义) */
|
||||
.source-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.radar-row { display: flex; align-items: center; gap: 10px; }
|
||||
.radar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; }
|
||||
.radar-bar-track {
|
||||
flex: 1; height: 8px; background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs); overflow: hidden;
|
||||
}
|
||||
.radar-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; }
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.radar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }
|
||||
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
<span class="branch-icon">⑂</span>
|
||||
{{ task.branch_name }}
|
||||
</span>
|
||||
<span class="task-date">{{ formatRelativeZh(task.created_at) }}</span>
|
||||
<span class="task-time">{{ formatRelativeZh(task.updated_at) }}</span>
|
||||
<span class="task-date">{{ formatRelative(task.created_at) }}</span>
|
||||
<span class="task-time">{{ formatRelative(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
|
||||
@@ -112,7 +112,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { formatRelativeZh } from '@/utils/time'
|
||||
import { formatRelative } from '@/utils/time'
|
||||
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
||||
import type { TaskRecord } from '@/api/types'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user