Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 57 additions & 68 deletions frontend/assets/css/agent.css

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions frontend/components/chat/AgentContextCompaction.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,19 @@ onUnmounted(() => { revision++ })

<style scoped>
.agent-compaction { margin:24px 0; min-width:0; color:var(--app-text-secondary,#737373); font-size:14px; line-height:1.7; }
/* 分隔提示跨出步骤缩进,遮住阅读轴;历史顺序和其他步骤布局保持原位。 */
.agent-process .agent-compaction { position:relative; margin:0 0 0 -18px; width:calc(100% + 18px); padding:24px 0; background:var(--ag-bg,#fff); }
/* 压缩状态和详情与其他步骤保持左对齐。 */
.agent-process .agent-compaction { position:relative; margin:0; width:100%; padding:24px 0; background:var(--ag-bg,#fff); }
.compaction-divider { display:flex; width:100%; align-items:center; gap:14px; padding:6px 0; border:0; background:transparent; color:inherit; font:inherit; }
.compaction-divider::before,.compaction-divider::after { content:''; flex:1; min-width:12px; border-top:1px solid var(--app-border,#e7e9ed); }
.compaction-divider::after { content:''; flex:1; min-width:12px; border-top:1px solid var(--app-border,#e7e9ed); }
.compaction-divider span { display:inline-flex; align-items:center; gap:8px; white-space:nowrap; }
button.compaction-divider { cursor:pointer; min-height:36px; }
button.compaction-divider:hover { color:var(--app-text-primary,#333); }
button:focus-visible { outline:2px solid var(--app-text-secondary,#737373); outline-offset:3px; border-radius:3px; }
.compaction-progress { text-align:center; }
.compaction-progress { text-align:left; }
.compaction-progress p { margin:3px 0 0; font-size:12px; }
.compaction-divider i { font-size:15px; }
.compaction-pulse { margin-top:9px; font-size:14px; animation:compaction-breathe 1.6s ease-in-out infinite; }
.compaction-details { padding:12px 14px; margin-top:6px; border:1px solid var(--app-border,#e7e9ed); border-radius:8px; overflow-wrap:anywhere; font-size:12px; }
.compaction-details { padding:12px 0; margin-top:6px; border:0; border-top:1px solid var(--app-border,#e7e9ed); border-radius:0; overflow-wrap:anywhere; font-size:12px; }
.compaction-details p { margin:0 0 8px; }
.compaction-details p:last-child { margin-bottom:0; }
.compaction-usage { font-variant-numeric:tabular-nums; }
Expand All @@ -82,5 +82,4 @@ button:focus-visible { outline:2px solid var(--app-text-secondary,#737373); outl
.compaction-details button { color:inherit; text-decoration:underline; cursor:pointer; }
@keyframes compaction-breathe { 0%,100% { opacity:.35; } 50% { opacity:1; } }
@media(prefers-reduced-motion:reduce) { .compaction-pulse { animation:none; } }
@container(max-width:480px) { .agent-process .agent-compaction { margin-left:-14px; width:calc(100% + 14px); } }
</style>
69 changes: 30 additions & 39 deletions frontend/components/chat/AgentRun.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<section class="agent-reply agent-run">
<ChainOfThought v-model="open" class="agent-process-panel" :class="{ 'is-open': open, 'is-running': running }" aria-label="执行过程">
<button class="agent-process-toggle" type="button" :aria-expanded="open" :aria-controls="`process-${run.id}`" :title="open ? '收起执行过程' : '展开执行过程与用量'" @click="toggle">
<button class="agent-process-toggle" type="button" :aria-expanded="open" :aria-controls="`process-${run.id}`" :title="open ? '收起执行过程' : '展开执行过程'" @click="toggle">
<span class="agent-process-title">{{ running ? '执行中' : '执行过程' }}</span>
<small class="agent-process-meta">{{ Math.floor(Math.max(0, elapsed || 0) / 60) }}分{{ Math.floor(Math.max(0, elapsed || 0)) % 60 }}秒</small>
<i :class="open ? 'fa-solid fa-chevron-down' : 'fa-solid fa-chevron-right'" aria-hidden="true" />
Expand All @@ -25,37 +25,24 @@
</template>
</div>
<div v-if="running && !(open && compacting) && !(open && run.subtasks?.running && run.stage === '执行独立子任务')" class="agent-live-step" role="status" aria-live="polite" aria-atomic="true">
<div><span class="agent-live-caption">AI 助手</span><span class="agent-stream-status agent-shimmer">{{ run.stage || (run.status === 'queued' ? '等待开始处理' : '正在查找与分析') }}</span><p v-if="liveHint" class="agent-live-hint">{{ liveHint }}</p><p v-if="stageElapsed >= 30" class="agent-wait-note">这一步仍在处理中,可随时补充要求或停止。</p></div>
<div><span class="agent-live-caption">AI 助手</span><span class="agent-stream-status agent-shimmer">{{ run.stage || (run.status === 'queued' ? '等待开始处理' : '正在查找与分析') }}</span></div>
<time aria-hidden="true">{{ duration(stageElapsed) }}</time>
</div>
<div v-show="open" class="agent-run-metadata">
<p v-if="!running && run.analysis?.known && run.coverage_state !== 'not_applicable'" class="agent-coverage-summary">已读取 {{ run.read_count || 0 }} 条<template v-if="run.analysis.tracked !== false"> · 已提交分析 {{ run.analysis.analyzed || 0 }} 条 · {{ run.analysis.complete ? '范围处理完成' : '范围尚未处理完成' }}</template><template v-else> · 按需检索</template></p>
<p v-else-if="!running && run.coverage_state !== 'not_applicable'" class="agent-coverage">此轮未记录完整遍历进度,范围覆盖情况未知。</p>
<p v-if="run.usage" class="agent-usage" :title="run.usage.unknown ? '部分调用未返回完整用量,当前显示已知部分。' : undefined">输入 {{ usagePending ? '待汇总' : (run.usage.input_tokens ?? '未知') }} · 输出 {{ usagePending ? '待汇总' : (run.usage.output_tokens ?? '未知') }} Token</p>
<div v-if="run.coverage_state !== 'not_applicable' && (run.source_count || run.analysis?.known)" class="agent-metadata-actions">
<button type="button" class="agent-materials-link" :aria-expanded="materialsOpen" :aria-controls="`materials-${run.id}`" @click="materialsOpen = !materialsOpen">{{ materialsOpen ? '收起来源与结果' : '来源与结果' }}<i :class="materialsOpen ? 'fa-solid fa-chevron-down' : 'fa-solid fa-chevron-right'" aria-hidden="true" /></button>
</div>
</div>
<AgentMaterials v-if="materialsOpen" v-show="open" :id="`materials-${run.id}`" :run="run" :name-for="nameFor" @close="materialsOpen = false" @locate="$emit('locate', $event)" />
</div>
</ChainOfThought>
<div v-if="run.error" class="agent-error" role="alert">{{ run.error }}<details v-if="run.error_info?.diagnostic_id"><summary>诊断信息</summary><small>{{ run.error_info.category }} · {{ run.error_info.diagnostic_id }}</small></details></div>
<div v-if="run.answer" class="agent-final-answer"><h3 class="agent-answer-heading">{{ run.status === 'completed' ? '最终回答' : running ? '正在回答' : '未完成的回答' }}</h3><AgentAnswer :text="run.answer" :citations="run.citations" :references="run.references" :streaming="running" @locate="$emit('locate', $event)" /></div>
<div v-if="run.answer" class="agent-final-answer"><h3 v-if="run.status !== 'completed'" class="agent-answer-heading">{{ running ? '正在回答' : '未完成的回答' }}</h3><AgentAnswer :text="run.answer" :citations="run.citations" :references="run.references" :streaming="running" @locate="$emit('locate', $event)" /></div>

<div v-if="run.choices?.length" class="agent-choices"><button v-for="choice in run.choices" :key="choice.username" type="button" @click="$emit('choose', choice)">{{ choice.name }}<small>{{ choice.username }}</small></button></div>
<div v-if="!running || (run.answer && run.coverage_warnings?.length)" class="agent-result-actions">
<AgentCopyAction v-if="!running && run.answer" :text="run.answer" :citations="run.citations" :references="run.references" />
<button v-if="!running && run.coverage_state !== 'not_applicable' && (run.citations?.length || run.answer)" type="button" :aria-label="evidenceOpen ? '收起出处' : '查看出处'" :aria-expanded="evidenceOpen" @click="evidenceOpen = !evidenceOpen"><i class="fa-solid fa-quote-left" aria-hidden="true" />出处</button>
<button v-if="run.answer && run.coverage_warnings?.length" type="button" class="agent-coverage-action" :aria-expanded="coverageOpen" :aria-controls="`coverage-${run.id}`" title="部分资料未读取,点击查看说明" @click="coverageOpen = !coverageOpen"><i class="fa-regular fa-circle-question" aria-hidden="true" />部分资料未读</button>
<div v-if="!running" class="agent-result-actions">
<div v-if="run.answer" class="agent-answer-footer">
<AgentCopyAction :text="run.answer" :citations="run.citations" :references="run.references" />
<span v-if="finalSummary" class="agent-final-summary" :title="finalSummaryTitle">{{ finalSummary }}</span>
</div>
<button v-if="!running && latest && run.error_info?.action === 'settings'" type="button" @click="$emit('settings')">检查 AI 服务</button>
<button v-else-if="!running && run.restart_required" type="button" @click="$emit('restart')"><i class="fa-solid fa-arrow-rotate-right" aria-hidden="true" />使用新引擎重新运行</button>
<button v-else-if="!running && latest && run.can_resume !== false && ['budget','failed','cancelled','interrupted'].includes(run.status)" type="button" @click="$emit('continue')"><i class="fa-solid fa-arrow-rotate-right" aria-hidden="true" />{{ run.status === 'failed' ? '重试这一步' : '继续查找' }}</button>
</div>
<div v-if="coverageOpen && run.answer && run.coverage_warnings?.length" :id="`coverage-${run.id}`" class="agent-coverage-explanation" role="region" aria-label="资料读取说明"><p v-for="warning in run.coverage_warnings" :key="warning">{{ warning }}</p></div>

<AgentEvidence v-if="evidenceOpen && (run.citations?.length || run.answer)" :run="run" @locate="$emit('locate', $event)" />



</section>
</template>
Expand All @@ -68,15 +55,32 @@ import ChainOfThought from '../ai-elements/chain-of-thought/ChainOfThought.vue'
import AgentCopyAction from './AgentCopyAction.vue'
import { groupTimelineTools } from '~/utils/agentTimeline'
import AgentAnswer from './AgentAnswer.vue'
import AgentEvidence from './AgentEvidence.vue'
import AgentMaterials from './AgentMaterials.vue'
import AgentContextCompaction from './AgentContextCompaction.vue'
const props = defineProps({run:{type:Object,required:true},now:Number,nearBottom:Boolean,latest:Boolean,nameFor:{type:Function,default:()=>''},viewState:{type:Object,required:true}})
defineEmits(['locate','choose','continue','restart','settings'])
// 阅读页在切回窗口或重新挂载时复用同一份展开状态。
const disclosure = name => computed({get:()=>!!props.viewState[`${props.run.id}:${name}`],set:value=>{props.viewState[`${props.run.id}:${name}`]=value}})
const evidenceOpen = disclosure('evidence'), materialsOpen = disclosure('materials'), coverageOpen = disclosure('coverage')
const running = computed(() => ['queued','running'].includes(props.run.status))
const formatTokenCount = value => {
if (!Number.isFinite(value) || value < 0) return '未知'
if (value < 1000) return String(Math.trunc(value))
const units = [[1000, 'k'], [10000, 'w'], [1000000, 'm']]
for (let i = 0; i < units.length; i++) {
const [scale, suffix] = units[i], next = units[i + 1]
const rounded = Number((value / scale).toFixed(1))
if (next && rounded * scale >= next[0]) continue
return `${rounded}${suffix}`
}
}
const summaryText = formatCount => {
const parts = [], run = props.run
if (run.coverage_state !== 'not_applicable' && Number.isFinite(run.read_count)) {
parts.push(`已读取 ${run.read_count} 条`)
if (run.analysis?.tracked === false) parts.push('按需检索')
}
if (run.usage) parts.push(`输入 ${formatCount(run.usage.input_tokens)} · 输出 ${formatCount(run.usage.output_tokens)} Token`)
return parts.join(' · ')
}
const finalSummary = computed(() => summaryText(formatTokenCount))
const finalSummaryTitle = computed(() => summaryText(value => Number.isFinite(value) && value >= 0 ? String(Math.trunc(value)) : '未知') + (props.run.usage?.unknown ? '(部分调用未返回完整用量,显示已知部分)' : ''))
// 有过程就默认展开,完成后不自动收起;仍保留用户手动收起的选择。
const open = computed({get:()=>props.viewState[props.run.id] ?? !(running.value && !records.value.length),set:v=>{props.viewState[props.run.id]=v}})
// 仅去掉与入口重复的当前阶段,保留已完成的同名阶段及阶段小结。
Expand All @@ -86,21 +90,8 @@ const isCompaction = item => item.kind === 'notice' && item.context_job?.id && N
const compactions = computed(() => records.value.filter(isCompaction))
const compacting = computed(() => compactions.value.some(item => item.context_job.status === 'running' && (item.input_version ?? props.run.version) === props.run.version))
const firstTaskRecord = computed(() => groupedRecords.value.find(item => item.kind === 'tool' && item.action === 'task')?.id)
// 尚未汇总的模型调用不显示成零 Token。
const modelCalls = computed(() => Math.max(props.run.used?.models || 0, props.run.usage?.calls || 0))
const usagePending = computed(() => running.value && modelCalls.value > (props.run.usage?.calls || 0))
const elapsed = computed(() => (props.run.elapsed_seconds || 0) + (running.value ? Math.max(0,props.now / 1000 - props.run.segment_started) : 0))
const stageElapsed = computed(() => Math.max(0,props.now / 1000 - (props.run.stage_started_at ?? props.run.segment_started ?? props.now / 1000)))
// 提示只来自已返回的进度,不编造模型思考或尚未完成的动作。
const liveHint = computed(() => {
if (props.run.subtasks?.running) return `正在执行 ${props.run.subtasks.running} 个子任务,已完成 ${props.run.subtasks.completed || 0}/${props.run.subtasks.total};展开子任务可查看各自进度。`
const scan = [...(props.run.timeline || [])].reverse().find(item => item.input_version === props.run.version && item.result?.realtime_coverage)?.result.realtime_coverage
if (scan && props.run.stage?.includes('搜索')) return `实时回查已检查 ${scan.scanned} 条,匹配 ${scan.matched} 条。`
const analysis=props.run.analysis
if(analysis?.known && analysis.tracked === false) return `已读取 ${props.run.read_count || 0} 条,按需检索。`
if(analysis?.known) return `已读取 ${props.run.read_count || 0} 条,已提交分析 ${analysis.analyzed || 0} 条${analysis.complete ? ',范围处理完成。' : '。'}`
return props.run.read_count ? `已读取 ${props.run.read_count} 条消息。` : ''
})
const duration = value => { const n=Math.max(0,Math.floor(value || 0)); return n>=60 ? `${Math.floor(n/60)}分${n%60}秒` : `${n}秒` }
const stageOutcome = status => ({running:'进行中',completed:'已完成',failed:'未完成',superseded:'已调整',cancelled:'已停止',paused:'已暂停',incomplete:'未完成'}[status] || '已结束')
const toggle = () => { open.value = !open.value }
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/chat/AgentSubtasks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ details[open] > summary > .disclosure-chevron { transform: rotate(90deg); }
.subtask-range, .subtask-goal { margin-top: 8px; }
.subtask-goal { line-height: 1.7; }
.subtasks-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 10px; }
.subtask-item { min-width: 0; padding: 14px 16px 0; border: 1px solid var(--app-border, #e7e7e7); border-radius: 12px; overflow: hidden; }
.subtask-item { min-width: 0; padding: 14px 0 0; border: 0; border-top: 1px solid var(--app-border, #e7e7e7); border-radius: 0; overflow: hidden; }
.subtask-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.subtask-heading strong { flex: 1 1 100%; font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.subtask-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--app-text-secondary, #666); }
Expand Down Expand Up @@ -185,7 +185,7 @@ h4 { margin: 0 0 8px; font-size: 12px; font-weight: 600; }
.subtask-findings p + p { margin-top: 12px; }
.subtask-objective { margin-top: 12px; }
.subtask-objective > summary { min-height: 32px; color: var(--app-text-secondary, #666); font-size: 12px; }
.subtask-objective p { max-height: 200px; overflow-y: auto; margin-top: 8px; padding: 12px; background: var(--app-surface-soft, #f7f7f7); border-radius: 6px; white-space: pre-wrap; font-size: 12px; }
.subtask-objective p { max-height: 200px; overflow-y: auto; margin-top: 8px; padding: 12px 0; background: var(--app-surface-soft, #f7f7f7); border-radius: 6px; white-space: pre-wrap; font-size: 12px; }
.subtask-usage { margin-top: 12px; color: var(--app-text-secondary, #666); font-size: 11px; }
.subtasks-empty { padding-block: 12px; color: var(--app-text-secondary, #666); }
.subtask-error { margin-block: 10px; color: var(--subtask-danger); }
Expand Down
Loading
Loading