新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
lxy
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11931 additions and 1033 deletions
+11 -2
View File
@@ -57,8 +57,11 @@
</div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny) -->
<DirAuthDialog @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
<!-- 待发送队列(生成中排队的消息,完成后自动续发) -->
<div v-if="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
@@ -126,6 +129,7 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { useAiStore } from '../stores/ai'
import { useAppSettingsStore } from '../stores/appSettings'
import ConfirmDialog from './ConfirmDialog.vue'
import ConversationSidebar from './ai/ConversationSidebar.vue'
import ChatInput from './ai/ChatInput.vue'
@@ -145,6 +149,11 @@ const props = defineProps<{
const store = useAiStore()
const { t } = useI18n()
const router = useRouter()
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
const appSettings = useAppSettingsStore()
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
// ── UX-2025-20: 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
+57 -3
View File
@@ -44,7 +44,33 @@
<div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div>
<div class="ai-tool-actions">
<!-- path_auth 审批链阶段3b: tc.kind 分两组按钮
- kind='path'(路径授权挂起):once/always/deny 三选项, authorizeDir
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项, approve -->
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
<button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving"
@click="onAuthorize('once')">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }}
</button>
<button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving"
@click="onAuthorize('always')">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onAuthorize('deny')">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }}
</button>
</div>
<div v-else class="ai-tool-actions">
<button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving"
@click="onApprove(true)">
@@ -127,8 +153,12 @@ const emit = defineEmits<{
toggle: [id: string]
/** read_file "展开全部/收起" 内容级展开 */
'expand-content': [id: string]
/** 审批按钮 → 父级转发 store.approveToolCall */
approve: [{ id: string; approved: boolean }]
/**
* 审批按钮 → 父级转发 store.approveToolCall。
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'always'|'deny')。
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
*/
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
}>()
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
@@ -189,6 +219,27 @@ async function onApprove(approved: boolean) {
emit('approve', { id: props.tc.id, approved })
}
/**
* path_auth 审批链阶段3b:path 类(路径授权挂起)三选项处理。
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
* 与 onApprove 同款 loading + 超时兜底,但 emit 带 decision(approved=false 仅占位,
* 实际语义由 decision 决定;调用方 store.approveToolCall 按 decision 走 authorizeDir)。
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,once/always/deny 三选项
* 本身就是用户明示决策,二次确认反增摩擦)。
*/
async function onAuthorize(decision: 'once' | 'always' | 'deny') {
if (approving.value) return // 防重入
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
// approved=false 仅占位:store.approveToolCall 据 decision 走 authorizeDir,approved 入参被忽略。
emit('approve', { id: props.tc.id, approved: false, decision })
}
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。原 cmdOutputExpanded/
// httpBodyExpanded 初始化逻辑已随结果区迁入 ToolResultBody,此处仅留 approving 复位。
watch(() => props.tc.status, (s) => {
@@ -350,6 +401,9 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
}
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
/* 审批 loading(B-260616-08):禁用两按钮防重复点击,spinner 转在原位 */
+6 -2
View File
@@ -60,8 +60,11 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
/** 审批按钮 → 父组件转发 store.approveToolCall */
approve: [{ id: string; approved: boolean }]
/**
* 审批按钮 → 父组件转发 store.approveToolCall。
* path_auth 审批链阶段3b:path 类带 decision('once'|'always'|'deny'),risk 类仅 approved boolean。
*/
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
/** 批量审批 → 父组件调用 approveAll/rejectAll */
'batch-approve': [decision: 'approve' | 'reject']
}>()
@@ -192,6 +195,7 @@ const TOOL_GROUP_I18N_KEY: Record<string, string> = {
read_file: 'readFile',
write_file: 'writeFile',
list_directory: 'listDirectory',
grep: 'grep',
create_task: 'createTask',
create_project: 'createProject',
create_idea: 'createIdea',
+115
View File
@@ -57,6 +57,44 @@
</div>
</div>
<!-- grep 结果:跨文件内容搜索(content/files_with_matches/count 三模式,F-260621)
对齐 search_files 文件栏样式;content 模式按命中行渲染(文件::内容),支持 -C 上下文折叠 -->
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">{{ parsed?.pattern }}</span>
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
</div>
<!-- files_with_matches 模式:仅文件列表 -->
<div v-if="parsed?.output_mode === 'files_with_matches'" class="ai-tool-dir-entries">
<div v-for="(f, i) in parsed?.files" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ f }}</span>
</div>
</div>
<!-- count 模式:文件 + 命中行数 -->
<div v-else-if="parsed?.output_mode === 'count'" class="ai-tool-dir-entries">
<div v-for="(c, i) in parsed?.counts" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ c.file }}</span>
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
</div>
</div>
<!-- content 模式:命中行(文件::内容),支持上下文折叠 -->
<div v-else class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-head">
<span class="ai-tool-grep-file">{{ m.file }}</span>
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">:{{ m.line }}</span>
</div>
<code class="ai-tool-grep-content">{{ m.content }}</code>
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
</div>
</div>
</div>
<!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
@@ -142,8 +180,10 @@ import {
formatSizeDiff,
combineOutputs,
formatToolResult,
formatGrep,
dirIcon,
fileIcon,
searchIcon,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import type { AiToolCallInfo } from '@/api/types'
@@ -201,6 +241,16 @@ const cmdOutput = computed(() => {
*/
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120))
/**
* grep 命中计数标签(F-260621):G-1 复用 useToolCard.formatGrep 单一函数,
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)。
* parsed 为 null 返空串,等价原逻辑;非 null 委托 formatGrep。
*/
const grepHitLabel = computed(() => {
const r = parsed.value
return r ? formatGrep(r) : ''
})
// SW-260618-06: status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
watch(() => props.tc.status, (s) => {
@@ -266,6 +316,71 @@ watch(() => props.tc.status, (s) => {
color: var(--df-text-dim);
flex-shrink: 0;
}
.ai-tool-file-meta--warn {
color: var(--df-warning);
}
/* grep 结果(F-260621):文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
.ai-tool-grep-result {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-grep-matches {
padding: 4px 0;
max-height: 260px;
overflow-y: auto;
}
.ai-tool-grep-match {
padding: 3px 12px;
border-bottom: 0.5px solid rgba(255,255,255,0.03);
}
.ai-tool-grep-match:last-child {
border-bottom: none;
}
.ai-tool-grep-match-head {
display: flex;
align-items: baseline;
gap: 1px;
font-family: var(--df-font-mono);
font-size: 10px;
margin-bottom: 1px;
}
.ai-tool-grep-file {
color: var(--df-accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-grep-line {
color: var(--df-text-dim);
flex-shrink: 0;
}
.ai-tool-grep-content {
display: block;
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-all;
background: rgba(61,219,160,0.05);
padding: 2px 6px;
border-radius: var(--df-radius-xs, 3px);
}
.ai-tool-grep-context {
margin: 2px 0 0;
padding: 0;
max-height: 120px;
overflow: auto;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.02);
border-radius: var(--df-radius-xs, 3px);
}
.ai-tool-grep-context code {
display: block;
padding: 4px 6px;
white-space: pre;
}
.ai-tool-expand-btn {
font-family: var(--df-font-sans);
font-size: 10px;
+40 -10
View File
@@ -119,7 +119,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord } from '../../api/types'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
const props = defineProps<{
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
@@ -252,17 +252,13 @@ const filteredSkills = computed(() => {
})
watch(inputText, (v) => {
if (pendingSkill.value) {
// chip
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
return
}
// Input Augmentation: skill chip + @ ( user intent) pendingSkill
// mention skill chip UI (),@ (),
// @
// / ( / )
skillOpen.value = v.startsWith('/')
if (skillOpen.value) skillIndex.value = 0
// UX-10: @ ( @ ,)
// UX-10: @ ( @ ,;)
if (skillOpen.value) {
mentionOpen.value = false
mentionStart.value = -1
@@ -307,6 +303,8 @@ function detectMentionTrigger() {
function selectSkill(s: SkillInfo) {
pendingSkill.value = s
inputText.value = ''
// selectSkill inputText,pendingMentionSpans , span
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
@@ -338,6 +336,10 @@ const mentionOpen = ref(false)
const mentionIndex = ref(0)
// @ inputText ( @query )
const mentionStart = ref(-1)
// Input Augmentation: pending mention (selectMention ,handleSend )
// span [: ] inputText {start,length} + kind/refId/label,
// mentionSpans resolve Augmentation ;MessageList chip
const pendingMentionSpans = ref<MentionSpan[]>([])
const mentionGroupLabel = computed(() => ({
project: t('aiChat.mentionGroupProject'),
@@ -424,6 +426,16 @@ function selectMention(item: MentionItem) {
? `[${t('aiChat.mentionGroupTask')}: ${item.name}]`
: `[${t('aiChat.mentionGroupIdea')}: ${item.name}]`
inputText.value = before + label + ' ' + after
// Input Augmentation: pending mention (start=before.length,label =label.length)
// before [: ] , before.length label
// refId id( resolve ),label chip (== == )
pendingMentionSpans.value.push({
start: before.length,
length: label.length,
kind: item.type,
refId: item.id,
label,
})
mentionOpen.value = false
mentionStart.value = -1
mentionIndex.value = 0
@@ -548,6 +560,15 @@ async function handleSend() {
// ai_chat_send IPC parts(Phase 2c ); parts push
// user , content
const snapshotImgs = pendingImages.value.slice()
// Input Augmentation: pending mention ( parts ,)
// :span.start inputText();text = inputText.trim()
// ,trim chip start
// start( trim chip start;chip )
const rawLeadingWs = inputText.value.length - inputText.value.replace(/^\s+/, '').length
const snapshotSpans = pendingMentionSpans.value.slice().map(sp => ({
...sp,
start: Math.max(0, sp.start - rawLeadingWs),
}))
const parts: ContentPart[] | undefined = snapshotImgs.length > 0
? [
// (,便 2c content parts ;
@@ -573,6 +594,8 @@ async function handleSend() {
inputText.value = ''
pendingSkill.value = null
pendingImages.value = []
// Input Augmentation: mention ( inputText ,)
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
@@ -580,7 +603,9 @@ async function handleSend() {
// sendMessage push
// push flush""
try {
await store.sendMessage(text, skill?.name, false, parts)
// Input Augmentation: mentionSpans(L0 doSend + user mentionSpans chip;
// L1 spans, useAiSend.sendMessage )
await store.sendMessage(text, skill?.name, false, parts, snapshotSpans.length > 0 ? snapshotSpans : undefined)
} catch (e) {
console.error('[AI] 发送失败:', e)
inputText.value = text
@@ -589,6 +614,10 @@ async function handleSend() {
if (parts) {
pendingImages.value = snapshotImgs
}
// : mention (,)
if (snapshotSpans.length) {
pendingMentionSpans.value = snapshotSpans
}
// ( console.error,);Tauri IPC Error
const msg = e instanceof Error ? e.message : String(e)
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
@@ -625,6 +654,7 @@ function clearInput(): void {
inputText.value = ''
pendingSkill.value = null
pendingImages.value = []
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
+2 -2
View File
@@ -233,7 +233,7 @@
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { formatRelativeZh } from '../../utils/time'
import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types'
const emit = defineEmits<{
@@ -329,7 +329,7 @@ function onSelectSearchResult(id: string) {
}
function formatTime(ts: string): string {
return formatRelativeZh(ts)
return formatRelative(ts)
}
// : / /
+54 -32
View File
@@ -1,41 +1,43 @@
<template>
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)
条件:pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)
"仅本次" ai_authorize_dir(decision=once, 写会话临时授权) 后端执行工具 + try_continue
<!-- F-260619-03 Phase B + path_auth 审批链阶段1: 路径授权弹窗(LLM 想访问白名单外目录,
后端挂起 loop 等用户决定)阶段1 改遍历 pendingDirAuths(数组)同轮多个文件工具落不同
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)
每项独立三按钮:"仅本次" ai_authorize_dir(decision=once, 写会话临时授权) 后端执行工具 + try_continue
"未来都允许" ai_authorize_dir(decision=always, 写持久化 Settings KV) 执行 + loop
"拒绝" ai_authorize_dir(decision=deny, 工具返 Err) loop
store 单例共享(state/aiApi),pendingDirAuth 模块级 ref(useAiEvents)直接导入
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入
toast emit 转父(保持单一 toast ) -->
<div v-if="showDirAuthCard" class="ai-dir-auth">
<div v-for="item in visibleDirAuths" :key="item.id" class="ai-dir-auth">
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
<span class="ai-dir-auth-hint">
{{ $t('aiChat.dirAuthHint', { tool: pendingDirAuth?.tool, path: pendingDirAuth?.path }) }}
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
</span>
<div class="ai-dir-auth-actions">
<button
class="ai-dir-auth-btn ai-dir-auth-btn--once"
:disabled="dirAuthActing"
@click="handleAuthorize('once')"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'once')"
>{{ $t('aiChat.dirAuthOnce') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--always"
:disabled="dirAuthActing"
@click="handleAuthorize('always')"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'always')"
>{{ $t('aiChat.dirAuthAlways') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
:disabled="dirAuthActing"
@click="handleAuthorize('deny')"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'deny')"
>{{ $t('aiChat.dirAuthDeny') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingDirAuth } from '../../composables/ai/useAiEvents'
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
import { aiApi } from '../../api'
const emit = defineEmits<{
@@ -45,37 +47,57 @@ const emit = defineEmits<{
const store = useAiStore()
const { t } = useI18n()
// (, MaxRoundsCard )
// F-260620 (): isGenerating(conv), streaming
// streaming (useAiStream onStreamTimeout), AiDirAuthRequired
// ( streaming) streaming=false generating=true
// generatingConvs conv(useAiEvents handleEvent / add) && streaming,
// loop () streaming,isGenerating(conv)
// F-09: isGenerating(active)
const isViewingGenerating = computed(() =>
store.state.streaming && store.isGenerating(store.state.activeConversationId),
store.isGenerating(store.state.activeConversationId),
)
// pendingDirAuth(AiDirAuthRequired )+ ()
const dirAuthActing = ref(false)
const showDirAuthCard = computed(() =>
pendingDirAuth.value !== null && isViewingGenerating.value,
)
// pendingDirAuths + ()
// 1:,visibleDirAuths pendingDirAuths ( conv ),
// showDirAuthCard computed()
const visibleDirAuths = computed(() => {
if (!isViewingGenerating.value) return []
// (active conv);
const active = store.state.activeConversationId
return pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === active)
})
/** : ai_authorize_dir remove pending / execute try_continue
* 不主动清 pendingDirAuth由后端 AiApprovalResult/AiCompleted/AiError useAiEvents 内清
* IPC 失败回滚 acting 让用户可重试 */
async function handleAuthorize(decision: 'once' | 'always' | 'deny'): Promise<void> {
const id = pendingDirAuth.value?.id
if (!id) return
dirAuthActing.value = true
// 1: id ""(, boolean )
// reactive Set disabled;Set O(1)
const actingIds = reactive(new Set<string>())
/** : id , ai_authorize_dir remove pending /
* execute try_continue不主动清 pendingDirAuths由后端 AiApprovalResult/AiCompleted/AiError
* useAiEvents 内按 id filter / 清空IPC 失败回滚 actingIds 让用户可重试 */
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> {
if (actingIds.has(id)) return
actingIds.add(id)
try {
await aiApi.authorizeDir(id, decision)
} catch (e) {
dirAuthActing.value = false
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
actingIds.delete(id) // IPC : id,
return
}
// F-260620 ():IPC try_continue spawn loop, loop
// AiDirAuthRequired( id , id ) pendingDirAuths ; id
// AiApprovalResult id filter pendingDirAuths actingIds
// watch pendingDirAuths, id () actingIds,
}
/** pendingDirAuth 离开挂起态(后端事件已清)时复位 acting,允许下次再操作 */
watch(() => pendingDirAuth.value, (v) => {
if (!v) dirAuthActing.value = false
/** pendingDirAuths ,( AiApprovalResult/AiCompleted/AiError id )
* id actingIds ,精准复位"处理中"(数组化后多挂起, watch===null 模式不再适用) */
watch(() => pendingDirAuths.value, (items) => {
const liveIds = new Set(items.map(p => p.id))
for (const id of [...actingIds]) {
if (!liveIds.has(id)) actingIds.delete(id)
}
})
</script>
+13 -5
View File
@@ -37,18 +37,26 @@ const emit = defineEmits<{
const store = useAiStore()
const { t } = useI18n()
// ()
// F-260620 ( max ): DirAuthDialog, streaming
// max(agentic:1186 guard.disarm + AiMaxRoundsReached,generating true),
// ,130s streaming streaming=false
// "/" generating max DirAuthAuth () streaming,
// isGenerating(conv) ( max generatingConvs conv,useAiEvents handleEvent / add)
// F-09: isGenerating(active)
const isViewingGenerating = computed(() =>
store.state.streaming && store.isGenerating(store.state.activeConversationId),
store.isGenerating(store.state.activeConversationId),
)
// pendingMaxRounds( max )+ ()
// pendingMaxRounds( max convId)+ ()
// TD-260621-02 per-conv:pendingMaxRounds convId(string|null),
// activeConversationId === ,F-09
// maxRoundsActing ref loading:/,
// (continueLoop AiAgentRound;stopLoopAiCompleted)
const maxRoundsActing = ref(false)
const showMaxRoundsCard = computed(() =>
pendingMaxRounds.value && isViewingGenerating.value,
!!pendingMaxRounds.value
&& pendingMaxRounds.value === store.state.activeConversationId
&& isViewingGenerating.value,
)
/** : ai_continue_loop max_iterations (iteration 0 )
@@ -82,7 +90,7 @@ async function handleStopLoop(): Promise<void> {
}
}
/** pendingMaxRounds 离开暂停态(后端事件已清)时复位 acting,允许下次再操作 */
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
watch(() => pendingMaxRounds.value, (v) => {
if (!v) maxRoundsActing.value = false
})
+129 -8
View File
@@ -18,9 +18,10 @@ import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
import ToolCardList from '../ToolCardList.vue'
import { formatRelativeZh, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart } from '../../api/types'
import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
defineProps<{
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
@@ -50,6 +51,14 @@ const {
getMarked,
getPurify,
} = useMarkdown()
// AR-1 Markdown ( B 退)
// appSettings key `df-ai-streaming-md`, true(): memo + rAF
// Markdown (//)(false):退
// (escapeFallback, delta ,<br> ) AR-1 ,/
// appSettings (,Settings )
const appSettings = useAppSettingsStore()
const streamingMdEnabled = computed(() => appSettings.get<boolean>('df-ai-streaming-md', true))
// _marked/_purify composable (getMarked/getPurify loadMarkdown null);
// parse getter ,
// memo: html(,O()O(),D/2)
@@ -130,7 +139,9 @@ interface StreamBlock {
}
function renderStreamingBlocks(text: string): StreamBlock[] {
if (!text) return []
if (!mdReady.value || !getMarked() || !getPurify()) {
// AR-1 MD 退(escapeFallback + <br>,)
// mdReady (marked )
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
return [{ html: escapeFallback(text), key: 'fallback' }]
}
const blocks = splitBlocks(text)
@@ -138,13 +149,34 @@ function renderStreamingBlocks(text: string): StreamBlock[] {
let tailSeq = 0 // , key
return blocks.map((b, i) => {
const isTail = i === n - 1
const html = isTail ? parseBlockNoCache(b) : parseBlock(b)
// AR-1 :(```/~~~ ,),
// marked.parse( marked + hljs ,
// delta parse /)(),AiCompleted
// renderMd ()()
const degrade = isTail && isUnclosedCodeFence(b)
const html = degrade
? escapeFallback(b)
: isTail ? parseBlockNoCache(b) : parseBlock(b)
// key(DOM );()
const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}`
return { html, key }
})
}
/// :( 3 ) ``` / ~~~ /,
/// = (),(splitBlocks
/// code token ) marked ( 3 + 3+ /)
function isUnclosedCodeFence(block: string): boolean {
let fenceCount = 0
const lines = block.split('\n')
for (const line of lines) {
// 3 + 3+ (marked )
const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line)
if (m) fenceCount++
}
return fenceCount % 2 === 1
}
/// : block key (, key)
function simpleHash(s: string): number {
let h = 5381
@@ -178,6 +210,62 @@ function renderContent(msg: AiMessage): string {
return renderMd(msg.content)
}
// Input Augmentation: mention (,)
// 🔴HIGH-2:( [: x] / AI 仿 / ] / )
// msg.mentionSpans() start :text chip
// span :content.slice(start, start+length) === span.label text
// (,/)/ span
type UserContentSegment =
| { type: 'text'; text: string }
| { type: 'chip'; span: MentionSpan }
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
const content = msg.content
const spans = msg.mentionSpans
// mentionSpans(/) text
if (!spans || spans.length === 0) {
return [{ type: 'text', text: content }]
}
// start (;)
const sorted = [...spans].sort((a, b) => a.start - b.start)
const segments: UserContentSegment[] = []
let cursor = 0 //
for (const span of sorted) {
const start = span.start
const end = start + span.length
// :/ content ()
if (start < 0 || end > content.length) continue
// : cursor ()
if (start < cursor) continue
// : === span.label( span)
if (content.slice(start, end) !== span.label) continue
// text()
if (start > cursor) {
segments.push({ type: 'text', text: content.slice(cursor, start) })
}
// chip
segments.push({ type: 'chip', span })
cursor = end
}
// tail text( span )
if (cursor < content.length) {
segments.push({ type: 'text', text: content.slice(cursor) })
}
// : span content segments (/tail), text
if (segments.length === 0 && content.length > 0) {
return [{ type: 'text', text: content }]
}
return segments
}
// UX-2025-20:
// (); i18n key , key
const examplePrompts = [
@@ -866,7 +954,16 @@ defineExpose({
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
{{ item.msg.content }}
<!-- Input Augmentation: mention 区间切段渲染(chip 精确替换 [类型: ] 标记,弃正则)
mentionSpans segmentUserContent 返回单段 text(零回归,等同原 {{ content }}) -->
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span
v-else
class="ai-msg-chip"
:class="'ai-msg-chip--' + seg.span.kind"
>{{ seg.span.label }}</span>
</template>
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
B-260618-01 虚拟滚动已移除,气泡内 <img> 随消息恒渲染无裁剪) -->
<template v-if="msgImageParts(item.msg).length">
@@ -899,7 +996,7 @@ defineExpose({
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelativeZh(item.msg.timestamp) }}</span>
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelative(item.msg.timestamp) }}</span>
</div>
<!-- AI 消息 -->
@@ -995,11 +1092,13 @@ defineExpose({
</div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path once/always/deny),
store.approveToolCall decision authorizeDir,缺省走 approve -->
<ToolCardList
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="item.msg.toolCalls"
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
/>
@@ -1008,7 +1107,7 @@ defineExpose({
v-if="!(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-time"
:title="formatDate(item.msg.timestamp)"
>{{ formatRelativeZh(item.msg.timestamp) }}</span>
>{{ formatRelative(item.msg.timestamp) }}</span>
</div>
</div>
</div>
@@ -1214,6 +1313,28 @@ defineExpose({
overflow-x: auto;
}
/* Input Augmentation: mention chip(, ChatInput .ai-skill-chip-name )
用户气泡背景 = --df-accent(主题强调色,如深色蓝/),chip 用半透明白底+深字 保证对比度;
kind 区分颜色变体(project/task/idea/skill), @ mention 浮层 .ai-mention-item-type--* 视觉呼应 */
.ai-msg-chip {
display: inline-block;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--df-radius);
font-size: 0.9em;
font-weight: 600;
line-height: 1.4;
background: rgba(255, 255, 255, 0.22);
color: #fff;
vertical-align: baseline;
white-space: nowrap;
}
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── 消息时间戳(UX-2025-13) ── */
.ai-msg-time {
font-size: 10px;
@@ -39,7 +39,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { formatRelativeZh } from '@/utils/time'
import { formatRelative } from '@/utils/time'
const store = useProjectStore()
const { t } = useI18n()
@@ -59,9 +59,9 @@ function getProjectTaskCount(projectId: string): number {
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
}
// utils/time.formatRelativeZh( Tasks/AuditLog/MessageList , NaN)
// utils/time.formatRelative( Tasks/AuditLog/MessageList , NaN)
// formatLastActivity ,
const formatLastActivity = formatRelativeZh
const formatLastActivity = formatRelative
const displayProjects = computed(() =>
store.projects.map(p => {
+63
View File
@@ -4,6 +4,24 @@
<h2 class="df-panel-title">{{ $t('dashboard.ideaPool') }}</h2>
<router-link to="/ideas" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
</div>
<div class="ideas-stats">
<div class="stat-item">
<span class="stat-num">{{ stats.total }}</span>
<span class="stat-label">{{ $t('ideas.statsTotal') }}</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.pending }}</span>
<span class="stat-label">{{ $t('ideas.statsPending') }}</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.promoted }}</span>
<span class="stat-label">{{ $t('ideas.statsPromoted') }}</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.avgScore }}</span>
<span class="stat-label">{{ $t('ideas.statsAvgScore') }}</span>
</div>
</div>
<div class="idea-rows">
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row">
<div class="idea-score-ring" :class="'ring-' + idea.tier">
@@ -39,6 +57,22 @@ const displayIdeas = computed(() =>
}))
)
// 4 (///)
// score null(),avgScore , ''
const stats = computed(() => {
const ideas = store.ideas
const scored = ideas.filter(i => i.score != null)
const avg = scored.length > 0
? Math.round(scored.reduce((sum, i) => sum + (i.score ?? 0), 0) / scored.length)
: null
return {
total: ideas.length,
pending: ideas.filter(i => i.status === 'draft' || i.status === 'pending_review').length,
promoted: ideas.filter(i => i.status === 'promoted').length,
avgScore: avg == null ? '—' : avg,
}
})
function ideaStatusLabel(status: string): string {
return t('dashboard.ideaStatus.' + status)
}
@@ -46,6 +80,35 @@ function ideaStatusLabel(status: string): string {
<style scoped>
/* 仅迁入灵感行自身样式;df-panel/df-link 等通用面板类留 Dashboard.vue(多面板共享) */
.ideas-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
}
.stat-num {
font-family: var(--df-font-mono);
font-size: 18px;
font-weight: 600;
color: var(--df-text);
line-height: 1.1;
}
.stat-label {
font-size: 10px;
color: var(--df-text-dim);
margin-top: 3px;
text-align: center;
}
.idea-rows { display: flex; flex-direction: column; }
.idea-row {
display: flex;
+400 -47
View File
@@ -5,16 +5,26 @@
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
</div>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail),空值回退 -->
<p
v-if="idea.description"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
<p v-else class="detail-desc"></p>
<template v-if="editing">
<textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea>
<div class="desc-edit-actions">
<button class="btn btn-primary btn-sm" @click="saveEdit">{{ $t('ideas.saveDesc') }}</button>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
</div>
</template>
<template v-else>
<p
v-if="idea.description"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
<p v-else class="detail-desc"></p>
<button class="btn btn-ghost btn-sm desc-edit-btn" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
</template>
<!-- 对抗式评估 -->
<div class="detail-section">
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t('ideas.evalModeHeuristic') }}</span></h3>
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t(evalModeLabelKey()) }}</span></h3>
<div v-if="adversarialEval" class="adversarial-eval">
<!-- 正反方观点 -->
<div class="debate-container">
@@ -47,7 +57,7 @@
<div class="analyst-conclusion">
<h4>{{ $t('ideas.analystTitle') }}</h4>
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
{{ assessmentLabel(adversarialEval.recommendation) }}
{{ localAssessmentLabel(adversarialEval.recommendation) }}
</div>
<p class="final-score">{{ $t('ideas.finalScore', { score: adversarialEval.final_score.toFixed(1) }) }}</p>
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
@@ -68,15 +78,95 @@
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
</button>
<div v-if="evalError" class="eval-error"> {{ evalError }}</div>
<div v-if="evalError" class="eval-error">
{{ evalError }}
<button class="btn-evaluate btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
{{ $t('ideas.retryEval') }}
</button>
</div>
</div>
</div>
<!-- 评估历史(版本时间线) -->
<div class="detail-section">
<h3>{{ $t('ideas.historyTitle') }}</h3>
<div v-if="historyLoading" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyLoading') }}</div>
<div v-else-if="evalHistory.length === 0" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyEmpty') }}</div>
<div v-else class="eval-history-list">
<div
v-for="rec in evalHistory"
:key="rec.id"
class="eval-history-item"
:class="{ expanded: expandedVersion === rec.version }"
@click="toggleVersion(rec.version)"
>
<div class="history-row-main">
<span class="version-badge">{{ $t('ideas.historyVersion') }} v{{ rec.version }}</span>
<span v-if="rec.version === latestVersion" class="latest-tag">{{ $t('ideas.historyLatest') }}</span>
<span class="history-eval-mode">{{ $t(evalModeLabelKeyFromStr(rec.evaluated_by)) }}</span>
<span class="history-score">{{ rec.score == null ? '—' : rec.score.toFixed(1) }}</span>
<span class="history-date">{{ formatDate(rec.evaluated_at) }}</span>
</div>
<div v-if="expandedVersion === rec.version" class="history-detail">
<template v-if="historySummary(rec.ai_analysis)">
<p class="history-summary">{{ historySummary(rec.ai_analysis)?.summary ?? '—' }}</p>
<p v-if="historySummary(rec.ai_analysis)?.recommendation" class="history-rec">
{{ historySummary(rec.ai_analysis)?.recommendation }}
</p>
</template>
<p v-else class="history-summary"></p>
</div>
</div>
</div>
</div>
<!-- 关联灵感 -->
<div class="detail-section">
<h3>{{ $t('ideas.relatedTitle') }}</h3>
<!-- 展示态 -->
<template v-if="!relatedEditing">
<div v-if="relatedIds.length > 0" class="related-list">
<template v-for="row in relatedRows" :key="row.id">
<router-link
v-if="row.title"
class="related-chip"
:to="`/ideas/${row.id}`"
>{{ row.title }}</router-link>
<span v-else class="related-chip related-chip-missing">#{{ row.id }}</span>
</template>
</div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
<button class="btn btn-ghost btn-sm" @click="startRelateEdit">{{ $t('ideas.relatedManage') }}</button>
</template>
<!-- 编辑态 -->
<template v-else>
<div v-if="candidateIdeas.length > 0" class="related-edit-list">
<label
v-for="idea in candidateIdeas"
:key="idea.id"
class="related-edit-item"
>
<input
type="checkbox"
:checked="relatedSelected.includes(idea.id)"
@change="toggleSelect(idea.id)"
/>
<span>{{ idea.title }}</span>
</label>
</div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
<div class="related-edit-actions">
<button class="btn btn-primary btn-sm" @click="confirmRelate">{{ $t('ideas.relatedConfirm') }}</button>
<button class="btn btn-ghost btn-sm" @click="cancelRelate">{{ $t('ideas.relatedCancel') }}</button>
</div>
</template>
</div>
<!-- 传统评分雷达图 -->
<div class="detail-section">
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
<div class="radar-chart" v-if="parseScores(idea).length > 0">
<div class="radar-row" v-for="dim in parseScores(idea)" :key="dim.name">
<div class="radar-chart" v-if="parseScores(idea.scores).length > 0">
<div class="radar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
<span class="radar-label">{{ dim.name }}</span>
<div class="radar-bar-track">
<div class="radar-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
@@ -117,6 +207,13 @@
>
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
</button>
<router-link
v-if="idea.promoted_to"
class="btn btn-primary"
:to="`/projects/${idea.promoted_to}`"
>
🚀 {{ $t('ideas.promotedProject') }}
</router-link>
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
</button>
@@ -126,11 +223,15 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseTags } from '../../stores/knowledge'
import { useProjectStore } from '../../stores/project'
import { useRendered } from '../../composables/useMarkdown'
import type { IdeaRecord, IdeaStatus } from '../../api/types'
import { ideaApi } from '../../api'
import { formatDate } from '../../utils/time'
import { parseScores, assessmentClass, assessmentLabel } from '../../utils/ideaEval'
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
const props = defineProps<{
idea: IdeaRecord
@@ -146,9 +247,82 @@ const emit = defineEmits<{
(e: 'promote'): void
(e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void
(e: 'update-desc', value: string): void
(e: 'update-related', ids: string[]): void
}>()
const { t } = useI18n()
const store = useProjectStore()
//
const editing = ref(false)
const editDesc = ref('')
function startEdit() {
editDesc.value = props.idea.description ?? ''
editing.value = true
}
function saveEdit() {
emit('update-desc', editDesc.value)
editing.value = false
}
function cancelEdit() {
editing.value = false
}
// ===== (related_ids JSON )=====
// props.idea.related_ids(JSON ,null// [])
const relatedIds = computed<string[]>(() => {
const raw = props.idea.related_ids
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
})
// :relatedIds store.ideas ( #id)
// 3 DRY/: v-for id relatedIdeas.find (O(n)×2);
// {id, title|null} (), v-for ,O(1)
const relatedRows = computed<{ id: string; title: string | null }[]>(() => {
// store.ideas Map ,O(1) , relatedId 线 store.ideas
const byId = new Map(store.ideas.map(i => [i.id, i.title] as const))
return relatedIds.value.map(id => ({ id, title: byId.get(id) ?? null }))
})
//
const relatedEditing = ref(false)
const relatedSelected = ref<string[]>([])
// ()
const candidateIdeas = computed(() => store.ideas.filter(i => i.id !== props.idea.id))
function startRelateEdit() {
relatedSelected.value = [...relatedIds.value]
relatedEditing.value = true
}
function confirmRelate() {
emit('update-related', relatedSelected.value)
relatedEditing.value = false
}
function cancelRelate() {
relatedEditing.value = false
}
function toggleSelect(id: string) {
const idx = relatedSelected.value.indexOf(id)
if (idx >= 0) {
relatedSelected.value.splice(idx, 1)
} else {
relatedSelected.value.push(id)
}
}
// status i18n key;退
function statusLabelKey(status: IdeaStatus): string {
@@ -161,23 +335,7 @@ const { rendered: renderedDesc } = useRendered(
() => props.idea.description ?? '',
)
interface ScoreDimension { name: string; score: number }
function parseScores(idea: IdeaRecord): ScoreDimension[] {
if (!idea.scores) return []
try {
const parsed = JSON.parse(idea.scores)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([name, score]) => ({
name,
score: typeof score === 'number' ? score : 0,
}))
}
return []
} catch {
return []
}
}
// parseScores / assessmentClass / assessmentLabel ../../utils/ideaEval ( ProjectDetail DRY )
interface AdversarialEval {
positive_strength: number
@@ -190,6 +348,7 @@ interface AdversarialEval {
positive: { thesis: string; evidence: string[] }
negative: { thesis: string; evidence: string[] }
analyst: { summary: string }
evaluated_by?: string
}
// ai_analysis JSON
@@ -202,25 +361,18 @@ const adversarialEval = computed<AdversarialEval | null>(() => {
}
})
function assessmentClass(recommendation: string) {
// CSS badge .immediate/.soon/.conditional/.revised/.defer/.cancel
const map: Record<string, string> = {
'immediate action': 'immediate',
'soon': 'soon',
'with resources': 'conditional',
'research more': 'revised',
'monitor': 'defer',
'cancel': 'cancel',
}
return map[recommendation.toLowerCase()] ?? 'conditional'
// (P0): evaluated_by i18n key
// 'Llm' LLM ;'HeuristicFallback' ;
// (undefined / 'Heuristic' / )
// evalModeLabelKeyFromStr ,(2 DRY)
function evalModeLabelKey(): string {
return evalModeLabelKeyFromStr(adversarialEval.value?.evaluated_by)
}
function assessmentLabel(recommendation: string) {
const key = `ideas.assessment.${recommendation}`
// i18n key 退 recommendation
const translated = t(key)
return translated === key ? recommendation : translated
}
// assessmentClass ../../utils/ideaEval( i18n ,)
// assessmentLabel t , t ,(1 DRY)
const localAssessmentLabel = (recommendation: string): string =>
assessmentLabel(t, recommendation)
function sentimentClass(sentiment: number) {
// (FR-C2: >=0 >0 net_sentiment=0 positive)
@@ -232,6 +384,63 @@ function sentimentClass(sentiment: number) {
function onStatusChange(e: Event) {
emit('status-change', (e.target as HTMLSelectElement).value as IdeaStatus)
}
// ===== (线)=====
const evalHistory = ref<IdeaEvaluationRecord[]>([])
const historyLoading = ref(false)
const expandedVersion = ref<number | null>(null)
async function loadHistory() {
historyLoading.value = true
try {
evalHistory.value = await ideaApi.listEvaluations(props.idea.id)
} catch {
evalHistory.value = []
} finally {
historyLoading.value = false
}
}
// = version ( DESC ,; max )
const latestVersion = computed(() =>
evalHistory.value.reduce((max, r) => Math.max(max, r.version), -1),
)
function toggleVersion(v: number) {
expandedVersion.value = expandedVersion.value === v ? null : v
}
// evaluated_by i18n key( evalModeLabelKey ,)
function evalModeLabelKeyFromStr(source: string | null | undefined): string {
if (source === 'Llm') return 'ideas.evalModeLlm'
if (source === 'HeuristicFallback') return 'ideas.evalModeFallback'
return 'ideas.evalModeHeuristic'
}
// ai_analysis JSON, summary / recommendation()
// LLM null
interface HistorySummary { summary: string | null; recommendation: string | null }
function historySummary(aiAnalysis: string | null | undefined): HistorySummary | null {
if (!aiAnalysis) return null
try {
const parsed = JSON.parse(aiAnalysis)
if (typeof parsed !== 'object' || parsed === null) return null
const obj = parsed as Record<string, unknown>
const summary = typeof obj.summary === 'string' ? obj.summary
: (typeof obj.analyst === 'object' && obj.analyst !== null
&& typeof (obj.analyst as Record<string, unknown>).summary === 'string'
? ((obj.analyst as Record<string, unknown>).summary as string)
: null)
const recommendation = typeof obj.recommendation === 'string' ? obj.recommendation : null
if (!summary && !recommendation) return null
return { summary, recommendation }
} catch {
return null
}
}
onMounted(loadHistory)
watch(() => props.idea.id, loadHistory)
</script>
<style scoped>
@@ -486,6 +695,85 @@ function onStatusChange(e: Event) {
vertical-align: middle;
}
/* ===== 评估历史(版本时间线)===== */
.eval-history-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.eval-history-item {
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.eval-history-item:hover {
background: var(--df-bg-card);
border-color: var(--df-accent);
}
.eval-history-item.expanded {
border-color: var(--df-accent);
}
.history-row-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
}
.version-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
background: rgba(108, 99, 255, 0.12);
color: var(--df-accent);
}
.latest-tag {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100, 255, 218, 0.15);
color: var(--df-success);
}
.history-eval-mode {
font-size: 11px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.12);
color: var(--df-warning);
}
.history-score {
font-size: 12px;
font-weight: 500;
color: var(--df-text);
min-width: 32px;
}
.history-date {
font-size: 11px;
color: var(--df-text-dim);
margin-left: auto;
}
.history-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 0.5px solid var(--df-border);
}
.history-summary {
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 0;
}
.history-rec {
font-size: 11px;
color: var(--df-accent);
margin: 6px 0 0;
}
/* ===== 雷达图(div 模拟) ===== */
.radar-chart {
display: flex;
@@ -535,6 +823,37 @@ function onStatusChange(e: Event) {
color: var(--df-accent);
}
/* ===== 关联灵感 ===== */
.related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.related-chip {
font-size: 12px; padding: 4px 10px;
border-radius: var(--df-radius-xs);
background: rgba(108, 99, 255, 0.1);
color: var(--df-accent);
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.related-chip:hover { background: rgba(108, 99, 255, 0.2); }
.related-chip-missing { cursor: default; opacity: 0.6; }
.related-edit-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.related-edit-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--df-text-secondary);
cursor: pointer;
}
.related-edit-item input { cursor: pointer; }
.related-edit-actions { display: flex; gap: 8px; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px;
@@ -549,6 +868,40 @@ function onStatusChange(e: Event) {
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* 小尺寸按钮(描述编辑/重试等) */
.btn-sm { padding: 4px 10px; font-size: 12px; }
.desc-edit-btn { margin-bottom: var(--df-gap-page); margin-top: 4px; }
.desc-edit-actions { display: flex; gap: 8px; margin-bottom: var(--df-gap-page); margin-top: 8px; }
/* 描述编辑 textarea */
.detail-desc-edit {
width: 100%;
padding: 8px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 14px;
line-height: 1.6;
font-family: inherit;
resize: vertical;
margin-bottom: var(--df-gap-page);
box-sizing: border-box;
}
.detail-desc-edit:focus {
outline: none;
border-color: var(--df-accent);
background: var(--df-bg-raised);
}
/* 评估失败重试按钮(内联于错误提示) */
.btn-retry {
margin-left: 8px;
padding: 2px 10px;
font-size: 11px;
vertical-align: middle;
}
/* ===== 状态管理 ===== */
.status-controls {
margin-top: 8px;