重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符 串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参, 增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
@@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
212
src/composables/ai/useToolApproval.ts
Normal file
212
src/composables/ai/useToolApproval.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 操作区:垃圾桶 + 压缩图标)
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
Reference in New Issue
Block a user