重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示

修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
2026-06-28 01:40:02 +08:00
parent e4ceb0015b
commit 53e1c1da77
19 changed files with 921 additions and 350 deletions

View File

@@ -301,11 +301,19 @@ export const aiApi = {
},
/**
* 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
* @param format 'markdown' | 'json' | 'txt'
*/
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
return invoke('ai_conversation_export', { conversationId, format })
},
}
/** 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
* @param format 'markdown' | 'json' | 'txt'
*/
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
return invoke('ai_conversation_export', { conversationId, format })
},
/**
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
*/
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
return invoke('ai_update_conversation_goals', { conversationId, goals })
},
}

View File

@@ -572,6 +572,7 @@ export interface AiConversationSummary {
title: string | null
provider_id: string | null
model: string | null
pinned_goals?: string[]
archived: boolean
pinned?: boolean
prompt_tokens?: number | null

View File

@@ -54,21 +54,21 @@
<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')">
@click="onAuthorizeOnce">
<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')">
@click="onAuthorizeAlways">
<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')">
@click="onDeny">
<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') }}
@@ -77,14 +77,14 @@
<div v-else class="ai-tool-actions">
<button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving"
@click="onApprove(true)">
@click="onApprove">
<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('aiTool.approve') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onApprove(false)">
@click="onReject">
<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('aiTool.reject') }}
@@ -124,10 +124,8 @@
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
import { computed, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import {
shouldKeepOpen,
parseResult,
@@ -137,6 +135,7 @@ import {
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval } from '@/composables/ai/useToolApproval'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue'
@@ -165,141 +164,24 @@ const emit = defineEmits<{
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
}>()
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
/**
* 审批按钮 loading 态(B-260616-08)。点击后置 true 禁用两按钮防重复点击 + spinner;
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。兜底计时到点复位 + toast 提示
* (对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
*/
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── 审批挂起时长显示(BUG-260624-02) ──
// 背景:AI 调用需审批的工具(patch_file/write_file 等)后,ToolCard 弹审批卡常驻
// 直到用户批/拒。用户可能走开忘了批,卡片无任何时长提示。
// 方案选型(A/B/C 三选一,选 C):
// A 不做(删 todo)——挂起审批容易被遗忘,AI loop 卡死
// B 加超时+倒计时——后端加 timer + 超时后行为难定义(自动拒=丢改动/自动批=危险)
// C 显示挂起时长(本方案)——纯前端展示,不动后端,不改审批行为
// 实现:进入 pending_approval 时记录起始时刻,每秒刷新 waitSecs。
// >2分钟变橙色,>5分钟变红色,提示用户尽快审批。
const pendingSince = ref<number | null>(null)
const waitSecs = ref(0)
let waitTimer: ReturnType<typeof setInterval> | null = null
function startPendingTimer() {
if (pendingSince.value !== null) return
pendingSince.value = Date.now()
waitSecs.value = 0
if (waitTimer) clearInterval(waitTimer)
waitTimer = setInterval(() => {
if (pendingSince.value !== null) {
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
}
}, 1000)
}
function stopPendingTimer() {
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
pendingSince.value = null
waitSecs.value = 0
}
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
// ── 审批超时兜底 toast(本卡自管,分离窗口运行时 App 根 toast 不在 DOM) ──
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
function showApproveTimeoutToast(): void {
approveToast.msg = t('aiTool.approveLoadingTimeout')
approveToast.visible = true
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
])
/** High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete')
}
return t('aiTool.confirmHighGeneric')
}
async function onApprove(approved: boolean) {
if (approving.value) return // 防重入
// AE-2025-05:High 风险 + 批准 → 先二次确认;拒绝不再二次确认(无害,加确认反增摩擦)
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
if (!ok) return
}
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
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) => {
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
stopPendingTimer()
} else {
startPendingTimer()
}
}, { immediate: true })
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
stopPendingTimer()
})
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
const {
approving,
approveToast,
waitSecs,
waitLevel,
formatWaitTime,
onApprove,
onReject,
onAuthorizeOnce,
onAuthorizeAlways,
onDeny,
confirmState,
answerConfirm,
} = useToolApproval(toRef(props, 'tc'), emit)
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
const parsed = computed(() => parseResult(props.tc.result))

View File

@@ -120,6 +120,25 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<!-- .4 Phase 4: @项目展开摘要(输入区 enrichment 预览) -->
<div v-if="hasEnrichment" class="ai-enrichment-bar">
<button
class="ai-enrichment-toggle"
:class="{ 'ai-enrichment-toggle--expanded': enrichmentExpanded }"
@click="enrichmentExpanded = !enrichmentExpanded"
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
>
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path d="M2 3l3 4 3-4" />
</svg>
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
</button>
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
</div>
</div>
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
</div>
</template>
@@ -382,6 +401,90 @@ const mentionStart = ref(-1)
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([])
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
// 在输入区下方显式 badge + 可展开的上下文参考面板。
interface ProjectEnrichment {
id: string
name: string
status: string
path: string | null
taskCount: number
ideaCount: number
inProgressTasks: { title: string; status: string }[]
pendingIdeas: { title: string; status: string }[]
}
const enrichmentExpanded = ref(false)
/** @项目 mentions 经 stores 反查后的 enrichment 摘要列表 */
const projectEnrichments = computed<ProjectEnrichment[]>(() => {
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
if (projectSpans.length === 0) return []
const projects = (projectsStore.projects || []) as ProjectRecord[]
const tasks = (projectsStore.tasks || []) as TaskRecord[]
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
const results: ProjectEnrichment[] = []
for (const span of projectSpans) {
const project = projects.find(p => p.id === span.refId)
if (!project) continue
const relTasks = tasks.filter(t => t.project_id === project.id)
// ideas linking to this project via promoted_to (project name matching)
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
const pending = relIdeas.filter(i => i.status === 'pending_review').slice(0, 20)
results.push({
id: project.id,
name: project.name,
status: project.status as string,
path: project.path,
taskCount: relTasks.length,
ideaCount: relIdeas.length,
inProgressTasks: running.map(t => ({ title: t.title, status: t.status as string })),
pendingIdeas: pending.map(i => ({ title: i.title, status: i.status as string })),
})
}
return results
})
/** 是否有 enrichment 可展示 */
const hasEnrichment = computed(() => projectEnrichments.value.length > 0)
/** 概要 badge 文本(汇总所有 @项目 entrichment) */
const enrichmentBadgeText = computed(() => {
const list = projectEnrichments.value
if (!list.length) return ''
const totalTasks = list.reduce((s, p) => s + p.taskCount, 0)
const totalIdeas = list.reduce((s, p) => s + p.ideaCount, 0)
const names = list.map(p => p.name).join('、')
return t('aiChat.enrichmentBadge', { project: names, tasks: totalTasks, ideas: totalIdeas })
})
/** 展开后的 enrichment 正文(mimic 后端 build_augmentation_segment 格式) */
const enrichmentDetailText = computed(() => {
const list = projectEnrichments.value
if (!list.length) return ''
const lines: string[] = []
for (const p of list) {
lines.push(`【项目】${p.name}(状态: ${p.status}`)
if (p.path) lines.push(`目录: ${p.path}`)
if (p.inProgressTasks.length > 0) {
lines.push(`进行中任务(${p.taskCount}):`)
for (const t of p.inProgressTasks) {
lines.push(` - ${t.title} (${t.status})`)
}
}
if (p.pendingIdeas.length > 0) {
lines.push(`待评估灵感(${p.ideaCount}):`)
for (const i of p.pendingIdeas) {
lines.push(` - ${i.title} (${i.status})`)
}
}
lines.push('')
}
return lines.join('\n')
})
const mentionGroupLabel = computed(() => ({
project: t('aiChat.mentionGroupProject'),
task: t('aiChat.mentionGroupTask'),
@@ -972,4 +1075,70 @@ defineExpose({
margin-top: 4px;
opacity: 0.6;
}
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
.ai-enrichment-bar {
margin-top: 6px;
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
overflow: hidden;
}
.ai-enrichment-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 8px;
border: none;
background: color-mix(in srgb, var(--df-accent) 10%, transparent);
color: var(--df-text);
cursor: pointer;
font-size: 11px;
text-align: left;
transition: background 0.15s;
}
.ai-enrichment-toggle:hover {
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
}
.ai-enrichment-toggle--expanded {
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
}
.ai-enrichment-chevron {
flex-shrink: 0;
color: var(--df-text-dim);
transition: transform 0.15s;
}
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
transform: rotate(180deg);
}
.ai-enrichment-badge {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
font-weight: 500;
}
.ai-enrichment-panel {
padding: 6px 8px;
background: var(--df-bg);
max-height: 200px;
overflow-y: auto;
}
.ai-enrichment-label {
font-size: 10px;
color: var(--df-text-dim);
margin-bottom: 4px;
font-weight: 600;
}
.ai-enrichment-body {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-all;
font-family: var(--df-font-mono, ui-monospace, monospace);
}
</style>

View File

@@ -20,34 +20,12 @@
{{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div>
</div>
<!-- F-01 阶段6: 模型选择器(自动/指定)
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话
provider model_configs(空池):下拉空 + 强制自动(toggle 禁用)
仅展示 active provider enabled model_configs -->
<div class="ai-model-picker" :title="modelPickerHint">
<div class="ai-model-mode">
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
:disabled="!enabledModels.length"
@click="setAutoMode"
>{{ $t('aiChat.autoMode') }}</button>
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
:disabled="!enabledModels.length"
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
@click="setSpecifyMode"
>{{ $t('aiChat.specifyMode') }}</button>
</div>
<!-- 模型选择器(对话透明化 L1:折叠为紧凑下拉) -->
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
:disabled="!isSpecifyMode"
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
@@ -114,20 +92,38 @@
</div>
</div>
<!-- 提供商状态 -->
<div
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
<!-- 下半栏:provider + goals 并排 -->
<div class="ai-bottom-bar">
<!-- 提供商状态 -->
<div
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<!-- 目标入口(扁平化,不抢眼) -->
<div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge" @click="goalsExpanded = !goalsExpanded">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.length }}
</span>
<div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
<span>{{ g }}</span>
<button class="ai-goal-remove" @click="removeGoal(i)">×</button>
</div>
</div>
</div>
</div>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model ,2s 后淡出) -->
<Transition name="ai-provider-switch">
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
@@ -144,6 +140,7 @@
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai'
import type { ModelConfig } from '../../api/types'
defineProps<{
@@ -194,9 +191,9 @@ const activeProviderName = computed(() => {
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
})
// ── F-01 阶段6: 顶部模型选择器(自动/指定) ──
// ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空 + 强制自动模式
// 空池(provider 未拉取/无 enabled 模型)时下拉为空。
const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default)
@@ -206,51 +203,59 @@ const activeProviderRecord = computed(() =>
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
// weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs。
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
const selectedModelId = computed(() => store.modelOverride.value || '')
/** 当前选中模型的 model_id(指定模式);自动模式为空 */
const modelPickerHint = computed(() => {
if (!enabledModels.value.length) return t('aiChat.noModels')
return isSpecifyMode.value
? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint')
})
/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id
}
function setAutoMode() {
store.modelOverride.value = null
}
function setSpecifyMode() {
if (!enabledModels.value.length) return
// 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留)
const cur = store.modelOverride.value
const inPool = cur && enabledModels.value.some(m => m.model_id === cur)
if (!inPool) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// UX-09 + 2026-06-18: 切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 不随机/不摇摆)。从 AiChat.vue 迁移(原与 editingMsgId/cancelEdit 同 watch,拆分后此 watch
// 仅管 modelOverride 默认值;编辑态取消留在 AiChat)。零行为变更。
// 切换会话默认选中权重最高 model
watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
})
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
// 从最新 switch 返回的 conversation summary 中取 pinned_goals,
// 增删操作经后端 IPC 持久化到 DB 并同步 per_conv 内存。
const goals = ref<string[]>([])
/** 目标列表展开/折叠 */
const goalsExpanded = ref(false)
/** 监听 activeConversationId 变化,加载目标的精简实现:从 store.conversations 查找当前会话的目标。 */
/** 监听 activeConversationId 变化,加载目标 */
watch(() => store.state.activeConversationId, (convId) => {
loadGoals(convId)
}, { immediate: true })
/** 消息变化后也刷新目标(发消息→save_conversation→DB→store 需重读) */
watch(() => store.state.messages.length, () => {
// loadConversations 异步,等 store 刷新后重读
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
})
function loadGoals(convId: string | null) {
if (!convId) { goals.value = []; return }
const conv = store.state.conversations.find(c => c.id === convId)
goals.value = conv?.pinned_goals ?? []
}
/** 移除第 i 个目标:删除后持久化全量列表到后端。 */
async function removeGoal(i: number) {
const convId = store.state.activeConversationId
if (!convId) return
const next = [...goals.value]
next.splice(i, 1)
goals.value = next
await aiApi.updateConversationGoals(convId, next)
}
// 循环切换 Provider(provider bar 点击)
function cycleProvider() {
const ps = store.state.providers
@@ -331,45 +336,15 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
margin-right: 4px;
padding-left: 8px;
min-width: 0;
}
.ai-model-mode {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.ai-model-mode-btn {
border: none;
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.ai-model-mode-btn:hover:not(:disabled) {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-model-mode-btn--active {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-model-mode-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ai-model-select {
min-width: 0;
max-width: 180px;
@@ -383,6 +358,11 @@ onBeforeUnmount(() => {
outline: none;
cursor: pointer;
}
.ai-model-select--compact {
max-width: 130px;
margin-left: auto;
margin-right: 4px;
}
.ai-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
@@ -432,19 +412,88 @@ onBeforeUnmount(() => {
animation: ai-btn-spin 0.9s linear infinite;
}
/* ═══ Provider Bar ═══ */
.ai-provider-bar {
/* ═══ 🎯 对话目标面板(对话透明化 L1) ═══ */
.ai-goals-panel {
padding: 6px 14px;
border-bottom: 0.5px solid var(--df-border);
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
}
.ai-goals-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
margin-bottom: 4px;
}
.ai-goals-title {
font-size: 11px;
font-weight: 500;
color: var(--df-accent);
}
.ai-goals-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-goal-item {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 0;
}
.ai-goal-icon {
font-size: 10px;
flex-shrink: 0;
}
.ai-goal-text {
font-size: 11px;
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ai-goal-remove {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.ai-goal-item:hover .ai-goal-remove {
opacity: 1;
}
.ai-goal-remove:hover {
color: var(--df-danger);
}
/* ═══ Provider Bar + Goals(并排) ═══ */
.ai-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px;
border-bottom: 0.5px solid var(--df-border);
min-height: 26px;
}
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--df-text-secondary);
white-space: nowrap;
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
background: var(--df-bg-card);
color: var(--df-text-primary);
}
.provider-dot {
width: 5px;
@@ -460,6 +509,58 @@ onBeforeUnmount(() => {
.ai-provider-empty {
background: var(--df-danger-bg);
}
/* 目标入口(扁平低调) */
.ai-goals-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-goals-inline-badge {
cursor: pointer;
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--df-text-dim);
background: transparent;
transition: color 0.15s, background 0.15s;
opacity: 0.6;
}
.ai-goals-inline-badge:hover {
opacity: 1;
background: var(--df-bg-card);
color: var(--df-text-secondary);
}
.ai-goals-inline-list {
position: absolute;
top: 100%; left: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg, #fff);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-goal-inline-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 3px 8px;
font-size: 11px;
}
.ai-goal-inline-item:hover {
background: var(--df-bg-card, #f5f5f5);
}
.ai-goal-remove:hover {
color: var(--df-danger, #e5484d);
}
.provider-hint {
font-size: 11px;
color: var(--df-danger);

View File

@@ -2,27 +2,39 @@
<div v-if="dag" class="wf-dag">
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
<!-- 节点列表 -->
<div class="wf-dag-nodes">
<div v-for="node in dag.nodes" :key="node.id" class="wf-dag-node">
<span class="wf-dag-node-icon"></span>
<span class="wf-dag-node-name">{{ node.id }}</span>
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
<!-- 拓扑分层渲染同层并行层间串行 -->
<div class="wf-dag-layers">
<div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer">
<div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div>
<div class="wf-dag-layer-nodes">
<div
v-for="node in layer"
:key="node.id"
class="wf-dag-node"
:class="nodeClass(node.id, props.nodeStatuses)"
>
<span class="wf-dag-node-status-dot"></span>
<span class="wf-dag-node-name">{{ node.id }}</span>
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
</div>
</div>
<!-- 层间箭头 -->
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow"></div>
</div>
</div>
<!-- 边列表带条件 -->
<div class="wf-dag-edges">
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
<span class="wf-dag-edge-arrow">{{ edge.source }} {{ edge.target }}</span>
<span v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">
{{ edge.condition }}
</span>
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">
无条件
</span>
<!-- 边条件列表折叠态hover 节点时参考 -->
<details class="wf-dag-edges-detail">
<summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件详情' }} ({{ dag.edges.length }})</summary>
<div class="wf-dag-edges">
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
<span class="wf-dag-edge-arrow">{{ edge.source }} {{ edge.target }}</span>
<span v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">{{ edge.condition }}</span>
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">{{ $t('taskDetail.workflowUnconditional') || '无条件' }}</span>
</div>
</div>
</div>
</details>
</div>
<div v-else class="wf-dag wf-dag--empty">
{{ $t('taskDetail.workflowDagEmpty') || '暂无工作流数据' }}
@@ -37,23 +49,23 @@ interface DagNode {
node_type: string
config?: Record<string, unknown>
}
interface EdgeDef {
source: string
target: string
condition?: string | null
}
export type NodeStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
const props = defineProps<{
const props = withDefaults(defineProps<{
dagJson: string
}>()
nodeStatuses?: Record<string, NodeStatus>
}>(), {
nodeStatuses: () => ({}),
})
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try {
return JSON.parse(props.dagJson)
} catch {
return null
}
try { return JSON.parse(props.dagJson) }
catch { return null }
})
const nodeTypeLabel: Record<string, string> = {
@@ -62,70 +74,120 @@ const nodeTypeLabel: Record<string, string> = {
'ScriptNode': '脚本',
'AiSelfReviewNode': 'AI 自审',
}
function statusLabel(s: NodeStatus): string {
return { pending: '⏳', running: '🔄', completed: '✅', failed: '❌', skipped: '⏭️' }[s] || ''
}
function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<string, boolean> {
const s = statuses[id]
return {
'wf-dag-node--running': s === 'running',
'wf-dag-node--completed': s === 'completed',
'wf-dag-node--failed': s === 'failed',
'wf-dag-node--skipped': s === 'skipped',
}
}
/** 拓扑分层:从无入边的根节点开始,逐层推进 */
const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
if (!dag.value) return []
const d = dag.value
// 入边计数
const inDegree: Record<string, number> = {}
const edgeMap: Record<string, string[]> = {}
for (const n of d.nodes) {
inDegree[n.id] = 0
edgeMap[n.id] = []
}
for (const e of d.edges) {
if (!edgeMap[e.source]) edgeMap[e.source] = []
edgeMap[e.source].push(e.target)
inDegree[e.target] = (inDegree[e.target] || 0) + 1
}
// Kahn 拓扑排序
const layers: (DagNode & { deps?: string[] })[][] = []
let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id)
const visited = new Set<string>()
while (queue.length > 0) {
const layer: DagNode[] = []
const next: string[] = []
for (const id of queue) {
if (visited.has(id)) continue
visited.add(id)
const node = d.nodes.find(n => n.id === id)
if (node) layer.push(node)
for (const target of (edgeMap[id] || [])) {
inDegree[target]--
if (inDegree[target] === 0) next.push(target)
}
}
if (layer.length > 0) layers.push(layer)
queue = next
}
return layers
})
</script>
<style scoped>
.wf-dag {
margin-top: 8px;
padding: 8px 12px;
margin-top: 8px; padding: 8px 12px;
background: var(--df-bg-card, #fafafa);
border-radius: var(--df-radius-sm, 4px);
font-size: 12px;
border-radius: var(--df-radius-sm, 4px); font-size: 12px;
}
.wf-dag--empty {
color: var(--df-text-dim, #888);
}
.wf-dag-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.wf-dag-nodes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
.wf-dag--empty { color: var(--df-text-dim, #888); }
.wf-dag-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
.wf-dag-layers { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.wf-dag-layer { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.wf-dag-layer-label { font-size: 10px; color: var(--df-text-dim, #888); align-self: flex-start; }
.wf-dag-layer-nodes {
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
}
.wf-dag-layer-arrow { font-size: 16px; color: var(--df-text-dim, #888); line-height: 1; }
.wf-dag-node {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px;
background: var(--df-bg, #fff);
border: 0.5px solid var(--df-border, #ddd);
border: 1px solid var(--df-border, #ddd);
border-radius: var(--df-radius-sm, 4px);
transition: border-color 0.3s, background 0.3s;
}
.wf-dag-node-icon { color: var(--df-text-dim, #888); }
.wf-dag-node-name { font-weight: 500; }
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
.wf-dag-node-type {
font-size: 10px;
color: var(--df-text-dim, #888);
background: var(--df-bg-card, #f0f0f0);
padding: 0 4px;
border-radius: 3px;
font-size: 10px; color: var(--df-text-dim, #888);
background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 3px;
}
.wf-dag-edges {
display: flex;
flex-direction: column;
gap: 3px;
.wf-dag-node-status { font-size: 11px; }
.wf-dag-node-status-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--df-text-dim, #ccc);
}
.wf-dag-edge {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
.wf-dag-node--running {
border-color: var(--df-info, #4a90e2);
background: rgba(74,144,226,0.05);
}
.wf-dag-node--running .wf-dag-node-status-dot {
background: var(--df-info, #4a90e2);
animation: pulse 1s ease-in-out infinite;
}
.wf-dag-node--completed { border-color: var(--df-success, #3ddba0); }
.wf-dag-node--completed .wf-dag-node-status-dot { background: var(--df-success, #3ddba0); }
.wf-dag-node--failed { border-color: var(--df-danger, #e5484d); background: rgba(229,72,77,0.05); }
.wf-dag-node--failed .wf-dag-node-status-dot { background: var(--df-danger, #e5484d); }
.wf-dag-node--skipped { opacity: 0.5; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.wf-dag-edges-detail { margin-top: 8px; }
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); }
.wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
.wf-dag-edge-arrow { color: var(--df-text-primary); }
.wf-dag-edge-cond {
font-family: var(--df-font-mono);
font-size: 11px;
padding: 1px 6px;
background: rgba(74, 144, 226, 0.08);
border-radius: 3px;
color: var(--df-info, #4a90e2);
}
.wf-dag-edge-cond--uncond {
background: transparent;
color: var(--df-text-dim, #aaa);
font-family: var(--df-font-mono); font-size: 11px; padding: 1px 6px;
background: rgba(74,144,226,0.08); border-radius: 3px; color: var(--df-info, #4a90e2);
}
.wf-dag-edge-cond--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
</style>

View File

@@ -578,8 +578,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
role: 'assistant',
content: t('ai.responseIncomplete'),
timestamp: Date.now(),
} as AiMessage)
})
}
// 对话透明化 L1:刷新会话列表(更新 pinned_goals)
void loadConversations()
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) {
state.lastTokenUsage = {

View File

@@ -0,0 +1,212 @@
/**
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
*
* 职责:审批按钮 loading 态、审批挂起时长显示(BUG-260624-02)、
* 审批超时兜底 toast(UX-260617-14)、approve/reject/path_auth 三选项处理、
* status 变化自动复位 + pending 计时。
*
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
*/
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import type { AiToolCallInfo } from '@/api/types'
/**
* 审批按钮 loading 超时兜底(B-260616-08)。
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
* 兜底计时到点复位 + toast 提示(对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
*/
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
])
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── B-260616-08: 审批 loading 态 ──
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── BUG-260624-02: 审批挂起时长显示 ──
const pendingSince = ref<number | null>(null)
const waitSecs = ref(0)
let waitTimer: ReturnType<typeof setInterval> | null = null
function startPendingTimer() {
if (pendingSince.value !== null) return
pendingSince.value = Date.now()
waitSecs.value = 0
if (waitTimer) clearInterval(waitTimer)
waitTimer = setInterval(() => {
if (pendingSince.value !== null) {
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
}
}, 1000)
}
function stopPendingTimer() {
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
pendingSince.value = null
waitSecs.value = 0
}
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
// ── UX-260617-14: 审批超时兜底 toast ──
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
function showApproveTimeoutToast(): void {
approveToast.msg = t('aiTool.approveLoadingTimeout')
approveToast.visible = true
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/** AE-2025-05:High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete')
}
return t('aiTool.confirmHighGeneric')
}
/**
* 批准按钮处理(risk 类专用)。
* AE-2025-05:High 风险工具 → 先二次确认(useConfirm 弹层)。
* loading + 超时兜底复用 B-260616-08 机制。
*/
async function onApprove() {
if (approving.value) return // 防重入
// AE-2025-05:High 风险 → 先二次确认
if (HIGH_RISK_TOOLS.has(tc.value.name)) {
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
if (!ok) return
}
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: true })
}
/**
* 拒绝按钮处理(risk 类专用)。
* loading + 超时兜底复用 B-260616-08 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。
*/
async function onReject() {
if (approving.value) return // 防重入
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false })
}
/**
* path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。
* 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。
*/
async function onAuthorizeOnce() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'once' })
}
/**
* path_auth 审批链阶段3b:path 类「写持久白名单」处理。
* 同 onAuthorizeOnce,decision='always'。
*/
async function onAuthorizeAlways() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'always' })
}
/**
* path_auth 审批链阶段3b:path 类「拒绝」处理。
* 同 onAuthorizeOnce,decision='deny'。
*/
async function onDeny() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'deny' })
}
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。
watch(() => tc.value.status, (s) => {
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
stopPendingTimer()
} else {
startPendingTimer()
}
}, { immediate: true })
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
stopPendingTimer()
})
return {
approving,
approveToast,
waitSecs,
waitLevel,
formatWaitTime,
onApprove,
onReject,
onAuthorizeOnce,
onAuthorizeAlways,
onDeny,
// 供模板 ConfirmDialog 绑定
confirmState,
answerConfirm,
}
}

View File

@@ -170,6 +170,12 @@ export default {
// 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',
// ── ⑥.4 Phase 4: @project expansion summary (input area preview) ──
enrichmentBadge: '📎 Referenced "{project}" ({tasks} tasks, {ideas} ideas)',
enrichmentExpand: 'Show context preview',
enrichmentCollapse: 'Hide context preview',
enrichmentLabel: 'Context preview',
// ── F-15 phase 2: manual context management (clear / compress) ──
// Buttons (Header action area: trash + compress icon)

View File

@@ -171,6 +171,12 @@ export default {
// 同名技能冲突(scan_skills 返回 conflicts,前端提示,注入按 skills>commands>plugins 优先级取首份)
skillConflict: '技能 {name} 存在多份定义,已采用 skills 优先级',
// ── ⑥.4 Phase 4: @项目展开摘要(输入区预览 ──
enrichmentBadge: '📎 已关联「{project}」({tasks} 项任务, {ideas} 项灵感)',
enrichmentExpand: '展开上下文参考',
enrichmentCollapse: '收起上下文参考',
enrichmentLabel: '上下文参考',
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// 按钮(Header 操作区:垃圾桶 + 压缩图标)

View File

@@ -86,9 +86,11 @@
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-else-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
</div>
<!-- 工作流 DAG 结构 -->
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
<div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value">
@@ -169,6 +171,7 @@ import {
import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import type { NodeStatus } from '@/components/workflow/WorkflowDagDisplay.vue'
const { t } = useI18n()
const route = useRoute()
@@ -202,7 +205,9 @@ const wfFailedHint = computed(() => wfResult.value === 'failed')
// 工作流 DAG 结构展示
const wfDagJson = ref('')
const wfNodeStatuses = ref<Record<string, NodeStatus>>({})
async function refreshWorkflowDag(execId: string) {
wfNodeStatuses.value = {}
try {
const record = await workflowApi.getExecution(execId)
if (record?.dag_json) wfDagJson.value = record.dag_json
@@ -365,12 +370,16 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
const node = String(evt.node_id ?? '')
wfRunningNode.value = node
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'running' }
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
break
}
case 'node_completed': {
wfDoneCount.value += 1
if (wfRunningNode.value) {
wfNodeStatuses.value = { ...wfNodeStatuses.value, [wfRunningNode.value]: 'completed' }
}
wfRunningNode.value = ''
break
}
@@ -387,7 +396,12 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
break
}
case 'workflow_failed':
case 'node_failed': {
case 'node_failed':
case 'workflow_failed': {
if (evt?.node_id) {
const node = String(evt.node_id)
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' }
}
if (evt?.type === 'workflow_failed') {
wfAdvancing.value = false
wfRunningNode.value = ''