代码提交

This commit is contained in:
董宏凯 committed 2026-06-10 17:44:44 +08:00
1 parent bd426ed1d3
commit d9afd8d887
15 files changed
+497 -229

No files matched your search

@@ -37,10 +37,10 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
const columnClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[linear-gradient(180deg,rgba(18,36,67,0.96),rgba(12,28,54,0.96))] shadow-[0_18px_40px_rgba(3,10,22,0.18)]'
: 'border border-[#E8E8E8] bg-[linear-gradient(180deg,#FFFFFF_0%,#FFF9F9_100%)] shadow-[0_14px_28px_rgba(245,34,45,0.06)]';
: 'border border-[#E8E8E8] bg-[linear-gradient(180deg,#FFFFFF_0%,#FFF9F9_100%)] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
return (
<section className={`flex h-full flex-col rounded-[28px] p-4 ${columnClass}`}>
<section className={`flex h-full flex-col rounded p-4 ${columnClass}`}>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
@@ -53,7 +53,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
{title}
</h2>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs ${
className={`inline-flex rounded-sm px-2 py-0.5 text-xs ${
isDark ? 'bg-[rgba(77,141,255,0.14)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'
}`}
>
@@ -76,14 +76,14 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
/>
</div>
<p className={`mt-2 text-xs leading-6 ${isDark ? 'text-slate-400' : 'text-[#8C8C8C]'}`}>
<p className={`mt-1.5 text-xs leading-5 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`}>
按时间倒序展示,支持分页切换。
</p>
<div className="mt-4 flex-1 space-y-4">
<div className="mt-3 flex-1 space-y-3">
{tasks.length === 0 ? (
<div
className={`flex h-full min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed ${
className={`flex h-full min-h-[220px] flex-col items-center justify-center rounded border border-dashed ${
isDark
? 'border-[rgba(87,111,149,0.42)] bg-[rgba(12,24,44,0.46)] text-slate-400'
: 'border-[#F3C4C8] bg-[#FFF7F7] text-[#A6A6A6]'
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, Bot, Layers, Plus, Search, Users, X } from 'lucide-react';
import { ArrowRight, Bot, CircleCheck, Inbox, Layers, Loader, Plus, Search, Users, X } from 'lucide-react';
import dayjs from 'dayjs';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
@@ -226,7 +226,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
const toolbarClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.94)] text-white shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_14px_30px_rgba(245,34,45,0.06)]';
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
const metricClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)]'
@@ -237,31 +237,31 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]';
const metrics = [
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal },
{ label: '未分析', value: unanalyzedTotal },
{ label: '分析中', value: runningTotal },
{ label: '已完成', value: completedTotal },
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal, icon: Layers, fg: '#222222', chipBg: '#F5F5F5', chipFg: '#7F7F7F' },
{ label: '未分析', value: unanalyzedTotal, icon: Inbox, fg: '#F5222D', chipBg: '#FEEDED', chipFg: '#F5222D' },
{ label: '分析中', value: runningTotal, icon: Loader, fg: '#1677FF', chipBg: '#EAF4FF', chipFg: '#1677FF' },
{ label: '已完成', value: completedTotal, icon: CircleCheck, fg: '#52C41A', chipBg: '#EEFCF7', chipFg: '#189D72' },
];
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1680px] flex-col gap-4 px-6 py-6 lg:px-8">
<section className={`rounded-[28px] p-5 ${toolbarClass}`}>
<section className={`rounded px-6 py-5 ${toolbarClass}`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs">
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}>
<div className="space-y-2.5">
<div className="flex items-center gap-1.5 text-xs">
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(108,161,255,0.24)] bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'border-[#FFD6D6] bg-[#FFF1F0] text-[#CF1322]'}`}>
{category}
</span>
<ArrowRight className={`h-3.5 w-3.5 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'bg-[#FFF6E8] text-[#D46B08]'}`}>
<ArrowRight className={`h-3 w-3 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(103,232,165,0.2)] bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'border-[#FFE2B8] bg-[#FFF6E8] text-[#D46B08]'}`}>
{subCategory}
</span>
</div>
<div>
<h1 className={`text-[28px] font-semibold ${isDark ? 'text-white' : 'text-[#222222]'}`}>{title}</h1>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
<h1 className={`text-[24px] font-semibold leading-8 ${isDark ? 'text-white' : 'text-[#222222]'}`}>{title}</h1>
<p className={`mt-1.5 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。
</p>
</div>
@@ -269,7 +269,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
<div className="flex flex-col gap-3 xl:items-end">
<div className="flex flex-wrap items-center gap-3">
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-xl border px-4 ${inputClass}`}>
<label className={`flex h-10 min-w-[300px] items-center gap-2 rounded-sm border px-3 ${inputClass}`}>
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`} />
<input
value={searchQuery}
@@ -281,6 +281,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
</label>
<DateRangePicker
className="sentiment-date-range"
size="large"
value={range}
presets={presets}
onChange={setRangeChange}
@@ -292,7 +293,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
<button
type="button"
className={`inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}
className={`inline-flex items-center gap-2 rounded-sm px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#F5222D] text-white hover:bg-[#CF1322]'}`}
>
<Plus className="h-4 w-4" />
新建任务
@@ -302,21 +303,30 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((item, index) => (
<div key={item.label} className={`rounded-[24px] p-5 ${metricClass}`}>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#8C8C8C]'}`}>{item.label}</p>
<p className={`mt-3 text-[36px] font-semibold ${isDark ? 'text-white' : index === 0 ? 'text-[#222222]' : 'text-[#F5222D]'}`}>
{item.value}
</p>
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}>
<Layers className="h-5 w-5" />
{metrics.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className={`rounded p-4 ${metricClass}`}>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#7F7F7F]'}`}>{item.label}</p>
<p
className={`mt-2 text-[30px] font-semibold leading-none ${isDark ? 'text-white' : ''}`}
style={isDark ? undefined : { color: item.fg }}
>
{item.value}
</p>
</div>
<div
className={`flex h-11 w-11 items-center justify-center rounded ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : ''}`}
style={isDark ? undefined : { backgroundColor: item.chipBg, color: item.chipFg }}
>
<Icon className="h-5 w-5" />
</div>
</div>
</div>
</div>
))}
);
})}
</section>
<section className="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
@@ -375,7 +385,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
{showAnalysisModal && selectedTask ? (
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/45 px-4">
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.98)] text-white' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_18px_36px_rgba(0,0,0,0.12)]'}`}>
<div className={`w-full max-w-[560px] rounded border p-6 ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.98)] text-white' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_8px_32px_rgba(34,34,34,0.16)]'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold">选择分析模式</h3>
@@ -386,7 +396,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
<button
type="button"
onClick={() => setShowAnalysisModal(false)}
className={`rounded-xl p-2 ${isDark ? 'hover:bg-[rgba(21,42,72,0.9)]' : 'hover:bg-[#FFF1F0]'}`}
className={`rounded-sm p-2 ${isDark ? 'hover:bg-[rgba(21,42,72,0.9)]' : 'hover:bg-[#FFF1F0]'}`}
>
<X className="h-4 w-4" />
</button>
@@ -396,7 +406,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
<button
type="button"
onClick={handleOpenTaskDetail}
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(110,168,255,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
className={`flex w-full items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(110,168,255,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div>
<div className="font-medium">查看任务详情</div>
@@ -410,7 +420,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
<button
type="button"
onClick={handleOpenConsultation}
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(103,232,165,0.42)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
className={`flex w-full items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(103,232,165,0.42)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-start gap-3">
<Users className={`mt-0.5 h-5 w-5 ${isDark ? 'text-[#89E8B8]' : 'text-[#CF1322]'}`} />
@@ -427,7 +437,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
<button
type="button"
onClick={handleSingleAgentAnalysis}
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(167,139,250,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
className={`flex w-full items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(167,139,250,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-start gap-3">
<Bot className={`mt-0.5 h-5 w-5 ${isDark ? 'text-[#C4B5FD]' : 'text-[#7C3AED]'}`} />
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, Bot, Layers, Plus, Search, Users, X } from 'lucide-react';
import { ArrowRight, Bot, CircleCheck, Inbox, Layers, Loader, Plus, Search, Users, X } from 'lucide-react';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
@@ -224,115 +224,105 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
? 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100'
: 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(245,34,45,0.08),transparent_26%),linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]';
const toolbarClass = isDark
? 'border border-[rgba(110,136,176,0.24)] bg-[rgba(10,23,42,0.94)] text-slate-100 shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
const metricCardClass = isDark
? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(13,31,55,0.98),rgba(8,19,34,0.98))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]'
: 'border border-[#E8E8E8] bg-white shadow-[0_12px_30px_rgba(245,34,45,0.06)]';
: 'border border-[#E8E8E8] bg-white';
const inputClass = isDark
? 'border-[rgba(110,136,176,0.3)] bg-[rgba(8,23,42,0.92)] text-slate-100 placeholder:text-slate-500'
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6] shadow-[0_4px_14px_rgba(245,34,45,0.04)]';
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]';
const panelGlowClass = isDark
? 'before:pointer-events-none before:absolute before:inset-x-8 before:top-0 before:h-px before:bg-[linear-gradient(90deg,transparent,rgba(96,165,250,0.45),transparent)]'
: '';
const metrics = [
{
label: '全部任务',
value: unanalyzedTotal + runningTotal + completedTotal,
icon: Layers,
accent: 'from-[#4D8DFF] to-[#7CC9FF]',
},
{
label: '未分析',
value: unanalyzedTotal,
icon: Bot,
accent: 'from-[#7C9BFF] to-[#5EEAD4]',
},
{
label: '分析中',
value: runningTotal,
icon: Layers,
accent: 'from-[#22C55E] to-[#67E8F9]',
},
{
label: '已完成',
value: completedTotal,
icon: Bot,
accent: 'from-[#38BDF8] to-[#A78BFA]',
},
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal, icon: Layers, fg: '#222222', chipBg: '#F5F5F5', chipFg: '#7F7F7F' },
{ label: '未分析', value: unanalyzedTotal, icon: Inbox, fg: '#F5222D', chipBg: '#FEEDED', chipFg: '#F5222D' },
{ label: '分析中', value: runningTotal, icon: Loader, fg: '#1677FF', chipBg: '#EAF4FF', chipFg: '#1677FF' },
{ label: '已完成', value: completedTotal, icon: CircleCheck, fg: '#52C41A', chipBg: '#EEFCF7', chipFg: '#189D72' },
];
return (
<div className={shellClass}>
<div className={`relative mx-auto flex min-h-full max-w-[1680px] flex-col gap-5 px-6 py-6 lg:px-8 ${panelGlowClass}`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div>
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>
{title}
</h1>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-300/80' : 'text-slate-500'}`}>
面向规划任务的任务分析看板,聚焦目标拆解、受众判断和策略阻力识别,支持快速检索、启动分析和查看会商结果。
</p>
</div>
</div>
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs ${isDark ? 'border-[rgba(110,136,176,0.28)] bg-[rgba(9,25,46,0.72)] text-slate-300' : 'border-slate-200 bg-white text-slate-600'}`}>
<span>{category}</span>
<ArrowRight className="h-3.5 w-3.5 opacity-70" />
<span>{subCategory}</span>
<div className={`relative mx-auto flex min-h-full max-w-[1680px] flex-col gap-4 px-6 py-6 lg:px-8 ${panelGlowClass}`}>
<section className={`rounded px-6 py-5 ${toolbarClass}`}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h1 className={`text-[24px] font-semibold leading-8 ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>{title}</h1>
<div className="flex items-center gap-1.5 text-xs">
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(108,161,255,0.24)] bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'border-[#FFD6D6] bg-[#FFF1F0] text-[#CF1322]'}`}>
{category}
</span>
<ArrowRight className={`h-3 w-3 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(103,232,165,0.2)] bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'border-[#FFE2B8] bg-[#FFF6E8] text-[#D46B08]'}`}>
{subCategory}
</span>
</div>
</div>
<p className={`mt-1.5 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
面向规划任务的任务分析看板,聚焦目标拆解、受众判断和策略阻力识别,支持快速检索、启动分析和查看会商结果。
</p>
<div className="flex flex-col gap-3 xl:items-end">
<div className="flex flex-wrap items-center gap-3">
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-2xl border px-4 ${inputClass}`}>
<Search className="h-4 w-4 text-[#7CC9FF]" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-sm outline-none"
placeholder="搜索任务名称、摘要或负责人"
/>
</label>
<DateRangePicker
className="sentiment-date-range"
value={dateRange}
presets={presets}
enableTimePicker
clearable
popupProps={{ overlayClassName: 'sentiment-date-range-popup' }}
onChange={setRangeChange}
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className={`flex h-10 min-w-[240px] flex-1 items-center gap-2 rounded-sm border px-3 ${inputClass}`}>
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`} />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-sm outline-none"
placeholder="搜索任务名称、摘要或负责人"
/>
</div>
</label>
<DateRangePicker
className="sentiment-date-range"
size="large"
value={dateRange}
presets={presets}
enableTimePicker
clearable
popupProps={{ overlayClassName: 'sentiment-date-range-popup' }}
onChange={setRangeChange}
/>
<button
type="button"
className={`inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}
className={`inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-sm px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#F5222D] text-white hover:bg-[#CF1322]'}`}
>
<Plus className="h-4 w-4" />
新建任务
</button>
</div>
</div>
</section>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map(({ label, value, icon: Icon, accent }) => (
<div key={label} className={`rounded-3xl p-5 ${metricCardClass}`}>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>{label}</p>
<p className={`mt-3 text-3xl font-semibold tracking-[0.03em] ${isDark ? 'text-white' : 'text-[#222222]'}`}>
{value}
</p>
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br ${accent} shadow-[inset_0_1px_0_rgba(255,255,255,0.18)]`}>
<Icon className="h-5 w-5 text-white" />
{metrics.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className={`rounded p-4 ${metricCardClass}`}>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#7F7F7F]'}`}>{item.label}</p>
<p
className={`mt-2 text-[30px] font-semibold leading-none ${isDark ? 'text-white' : ''}`}
style={isDark ? undefined : { color: item.fg }}
>
{item.value}
</p>
</div>
<div
className={`flex h-11 w-11 items-center justify-center rounded ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : ''}`}
style={isDark ? undefined : { backgroundColor: item.chipBg, color: item.chipFg }}
>
<Icon className="h-5 w-5" />
</div>
</div>
</div>
</div>
))}
);
})}
</div>
<div className="grid auto-rows-auto grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
@@ -342,7 +332,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<KanbanColumn
title="未分析"
count={unanalyzedTotal}
color="bg-[#7C9BFF]"
color={isDark ? 'bg-[#7CC9FF]' : 'bg-[#F5222D]'}
tasks={unanalyzedList}
taskType="unanalyzed"
pageIndexSearch={pageIndexChange}
@@ -358,7 +348,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<KanbanColumn
title="分析中"
count={runningTotal}
color="bg-[#22C55E]"
color={isDark ? 'bg-[#5EEAD4]' : 'bg-[#1677FF]'}
tasks={runningList}
taskType="running"
pageIndexSearch={pageIndexChange}
@@ -374,7 +364,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<KanbanColumn
title="已完成"
count={completedTotal}
color="bg-[#38BDF8]"
color={isDark ? 'bg-[#67E8A5]' : 'bg-[#52C41A]'}
tasks={completedList}
taskType="completed"
pageIndexSearch={pageIndexChange}
@@ -387,19 +377,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
</div>
{showAnalysisModal && selectedTask ? (
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-[rgba(3,10,22,0.62)] px-4 backdrop-blur-sm">
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(110,136,176,0.26)] bg-[linear-gradient(180deg,rgba(10,29,54,0.98),rgba(8,20,37,0.98))] text-slate-100 shadow-[0_28px_80px_rgba(2,8,20,0.42)]' : 'border-slate-200 bg-white text-slate-900 shadow-xl'}`}>
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/45 px-4">
<div className={`w-full max-w-[560px] rounded border p-6 ${isDark ? 'border-[rgba(110,136,176,0.26)] bg-[linear-gradient(180deg,rgba(10,29,54,0.98),rgba(8,20,37,0.98))] text-slate-100 shadow-[0_28px_80px_rgba(2,8,20,0.42)]' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_8px_32px_rgba(34,34,34,0.16)]'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold">选择分析模式</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
当前任务:{selectedTask.taskName}
</p>
</div>
<button
type="button"
onClick={() => setShowAnalysisModal(false)}
className={`rounded-xl p-2 transition ${isDark ? 'hover:bg-[rgba(15,41,74,0.88)]' : 'hover:bg-slate-100'}`}
className={`rounded-sm p-2 transition ${isDark ? 'hover:bg-[rgba(15,41,74,0.88)]' : 'hover:bg-[#FFF1F0]'}`}
>
<X className="h-4 w-4" />
</button>
@@ -409,15 +399,18 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<button
type="button"
onClick={handleOpenTaskDetail}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(96,165,250,0.48)] hover:bg-[rgba(12,38,69,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-sky-200 hover:bg-sky-50'}`}
className={`group flex items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(96,165,250,0.48)] hover:bg-[rgba(12,38,69,0.96)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#1677FF,#0EA5E9)]">
<Layers className="h-5 w-5 text-white" />
<div
className={`flex h-10 w-10 items-center justify-center rounded-sm ${isDark ? 'bg-[linear-gradient(180deg,#1677FF,#0EA5E9)] text-white' : ''}`}
style={isDark ? undefined : { backgroundColor: '#EAF4FF', color: '#0079FE' }}
>
<Layers className="h-5 w-5" />
</div>
<div>
<div className="font-medium">查看任务详情</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-[#575757]'}`}>
进入 B1 系统查看任务详情。
</div>
</div>
@@ -428,15 +421,18 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<button
type="button"
onClick={handleOpenConsultation}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(45,212,191,0.48)] hover:bg-[rgba(10,42,58,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-emerald-200 hover:bg-emerald-50'}`}
className={`group flex items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(45,212,191,0.48)] hover:bg-[rgba(10,42,58,0.96)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#14B8A6,#22C55E)]">
<Users className="h-5 w-5 text-white" />
<div
className={`flex h-10 w-10 items-center justify-center rounded-sm ${isDark ? 'bg-[linear-gradient(180deg,#14B8A6,#22C55E)] text-white' : ''}`}
style={isDark ? undefined : { backgroundColor: '#FEEDED', color: '#CF1322' }}
>
<Users className="h-5 w-5" />
</div>
<div>
<div className="font-medium">多智能体会商</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-[#575757]'}`}>
多个 AI 智能体从不同视角开展讨论并形成共识。
</div>
</div>
@@ -447,15 +443,18 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<button
type="button"
onClick={handleSingleAgentAnalysis}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(167,139,250,0.48)] hover:bg-[rgba(28,24,58,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-violet-200 hover:bg-violet-50'}`}
className={`group flex items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(167,139,250,0.48)] hover:bg-[rgba(28,24,58,0.96)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#7C3AED,#A78BFA)]">
<Bot className="h-5 w-5 text-white" />
<div
className={`flex h-10 w-10 items-center justify-center rounded-sm ${isDark ? 'bg-[linear-gradient(180deg,#7C3AED,#A78BFA)] text-white' : ''}`}
style={isDark ? undefined : { backgroundColor: '#F3EDFF', color: '#7C3AED' }}
>
<Bot className="h-5 w-5" />
</div>
<div>
<div className="font-medium">单智能体分析</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-[#575757]'}`}>
由单个 AI 智能体直接对该任务进行分析问答。
</div>
</div>
@@ -118,7 +118,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
const panelClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(19,39,72,0.9)] shadow-[0_14px_34px_rgba(3,10,22,0.2)] hover:border-[rgba(110,168,255,0.52)]'
: 'border border-[#E8E8E8] bg-white shadow-[0_10px_24px_rgba(245,34,45,0.06)] hover:border-[#F6B0B4] hover:shadow-[0_16px_32px_rgba(245,34,45,0.12)]';
: 'border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] hover:border-[#F6B0B4] hover:shadow-[0_4px_16px_rgba(34,34,34,0.12)]';
const typeBadgeClass = isDark
? 'bg-[rgba(77,141,255,0.14)] text-[#A9D2FF] border border-[rgba(108,161,255,0.24)]'
@@ -151,25 +151,25 @@ export const TaskCard: React.FC<TaskCardProps> = ({
return (
<article
className={`group rounded-[24px] p-4 transition-all duration-200 ${panelClass}`}
className={`group rounded p-4 transition-all duration-200 ${panelClass}`}
onClick={handleCardClick}
>
<div className="flex items-start justify-between gap-3">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${typeBadgeClass}`}
className={`inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs font-medium ${typeBadgeClass}`}
>
<TypeIcon className="h-3.5 w-3.5" />
{typeConfig.label}
</span>
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${statusClass}`}
className={`inline-flex items-center rounded-sm px-2 py-1 text-xs font-medium ${statusClass}`}
>
{getColumnLabel(task.column)}
</span>
</div>
<h3
className={`mt-5 text-[16px] font-semibold leading-7 ${
className={`mt-4 text-[16px] font-semibold leading-6 ${
isDark ? 'text-white' : 'text-[#222222]'
}`}
>
@@ -177,9 +177,10 @@ export const TaskCard: React.FC<TaskCardProps> = ({
</h3>
<p
className={`mt-3 min-h-[56px] text-sm leading-7 ${
className={`mt-3 line-clamp-3 min-h-[63px] text-sm leading-[21px] ${
isDark ? 'text-slate-300' : 'text-[#575757]'
}`}
title={task.overview || undefined}
>
{task.overview || '暂无任务摘要'}
</p>
@@ -204,9 +205,9 @@ export const TaskCard: React.FC<TaskCardProps> = ({
<button
type="button"
onClick={handleActionClick}
className={`mt-5 flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-medium transition ${actionClass}`}
className={`mt-4 flex h-9 w-full items-center justify-center gap-1.5 rounded-sm text-sm font-medium transition ${actionClass}`}
>
{isCompleted ? <Eye className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{isCompleted ? <Eye className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
{isCompleted ? '查看结果' : '开始分析'}
</button>
</article>
@@ -7,12 +7,10 @@ one stream and never parses LangGraph internals.
Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
``bash`` / ``ask_clarification``) so a seat can neither write files (parallel
writes would clash), dispatch / run scripts (a weak model fed "总控 / 业务链条"
framing would otherwise mimic the controller and "派活"), nor pause to ask the
user for help (this panel is an unattended parallel fan-out — a seat short on
info makes a reasonable assumption and states it in its answer instead of
interrupting). Read-only tools stay on, so the step bar still works.
``bash``) so a seat can neither write files (parallel writes would clash) nor
dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise
mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on,
so the step bar and clarification cards still work.
Unified event protocol (one JSON object per ``data:`` frame)::
@@ -60,21 +58,18 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8
# 禁三类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本
# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用
# glob / bash 翻找派活脚本)。③**用户协助**(ask_clarification)—— 本面板是「无人值守」
# 的并行扇出,没有让单个席位中途打断、向用户要澄清的交互位;禁掉后席位遇到信息不足时
# 自行做合理假设并把假设写进正文交付,而不是挂起等人。物理禁掉这三类即可根治。其余只读
# 工具(ls / read_file / 检索)保留:既不互相打架,又能正常出步骤条。
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
_EXCLUDED_TOOLS: list[str] = [
"write_file", # 并行不互相写文件打架
"str_replace",
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
"ask_clarification", # 禁「用户协助」:并行无人值守,不向用户要澄清,自己合理假设并交付
]
@@ -281,13 +276,12 @@ def _build_task_message(task_name: str, brief: str) -> str:
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
)
# 无人值守护栏:本轮并行执行没有让你中途向用户要澄清的交互位(已禁用用户协助)。遇到
# 信息不足时,请基于已有信息做出**合理假设**并在正文中**注明你的假设**,照常给出交付,
# 不要反问用户、不要把交付挂起等待补充。
# 澄清护栏:要向用户确认时,必须用 ask_clarification 工具发起(前端据此渲染可点击的协作
# 卡片),不能只把问题写在正文里 —— 否则用户看不到卡片、无法回应。能合理假设就先假设并标注。
parts.append(
"【无人值守】本轮为并行无人值守执行,没有与你来回澄清的环节。若信息不足,请自行做出"
"**合理假设**并在交付中**明确写出你假设了什么**,然后照常完成分析;不要反问用户、不要"
"要求补充信息后再继续,也不要把结论挂起等待确认。"
"【需要向用户确认时】如果确实缺少完成分析所必需的关键信息,请**调用 `ask_clarification` 工具**"
"发起确认(这样用户会看到一张可点击的协作卡片来回答);**不要只把问题写在正文里**,否则用户无法回应。"
"能基于已有信息合理假设的,就先假设并在正文中标注你的假设;只有当不同假设会显著改变结论时,才发起确认。"
)
return "\n\n".join(parts)
+40 -21
View File
@@ -51,6 +51,8 @@ src/
├─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式 + 工具类子集(无 Tailwind)
├─ ParallelAgentsPanel.vue 主组件:席位条/会话区 + 全部状态编排(表单/按钮在宿主侧)
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/clarify/error 角标,点击切换单显)
├─ AutoScrollPane.vue 自动下滑滚动窗格:包住单席位消息,超高出滚动条 + 流式自动钉底 +
│ 用户滚动暂停 + 半透明「自动滚动」浮动按钮(每席位一实例,互不影响)
├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片
├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2)
├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey)
@@ -100,7 +102,7 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
| `agent_start` | `agent_id` | 该席位置 running |
| `delta` | `agent_id, text` | 追加正文(节流) |
| `step` | `agent_id, key(=`tool_calling:<tool>`), label, args` | 步骤条加一步(含完整 args) |
| `agent_clarification`〔已禁用〕 | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片;席位置 **clarify**(待协助,非 done)。**注**:后端已把 `ask_clarification` 加入 `_EXCLUDED_TOOLS`,本面板**无人值守**不再发该事件;前端协作卡片相关代码保留(兜底,正常不触发) |
| `agent_clarification` | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片;席位置 **clarify**(待协助,非 done)。澄清卡片**任何时候都可点**(即便整批仍在跑),用户答完 → `panel.onClarify` → `rerunAgent` 重跑该席位 |
| `agent_done` | `agent_id, content` | 收口,正文以终态为准 |
| `agent_error` | `agent_id, message` | 该席位置 error |
| `all_done` | — | 全部结束 |
@@ -122,17 +124,30 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
```
ParallelAgentsPanel
├─ ParallelSeatStrip ── Avatar (席位条,点头像切换单显)
└─ MessageBubble (当前选中席位)
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
└─ AutoScrollPane (当前选中席位;限高滚动 + 流式自动下滑 + 浮动「自动滚动」按钮)
└─ MessageBubble
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
```
> **自动下滑 / 高度自适应**(`AutoScrollPane.vue`):会话区只负责限高(`min-h-0 flex-1 overflow-hidden`),
> 真正的滚动与自动下滑下放到**每个席位卡片内部**的窗格 —— 父元素不够高时窗格内出现滚动条;模型流式输出时
> 用 `ResizeObserver` 监听内容增高并钉到底部;用户向上滚动(离开底部)即暂停自动下滑、滚回底部自动恢复;
> 暂停且内容可滚动时底部居中浮出半透明「自动滚动」按钮(`.pa-autoscroll-btn`),点击恢复并滚到底。
> 每个席位各一个实例(`v-for :key=agentId`),autoScroll / 滚动位置都在实例内,**多智能体互不影响**。
> 席位卡片靠 `.pa-rows-fill`(`grid-auto-rows: minmax(0,1fr)`)受限于会话区高度,从而内部滚动而非撑破容器。
`ParallelAgentsPanel` 的对外接口(详见 [07-使用说明.md](./07-使用说明.md)):
- props:`agents`(必填) / `username` / `baseUrl` / `token` / `getToken` / `taskId` / `model` / `dark`
- emits:`saved(SavedTask)` / `update:taskId(string)` / `completed(AgentResult[])`
- `defineExpose`:`getResults()` / `getResult(id)` / `start()` / `cancel()`
- emits:
- `agent-completed(AgentReply)` —— **单个**智能体**真正完成(done)**时各触发一次;`AgentReply = {agent_id, name, content}`
(智能体名称 + 模型返回结果,**不含 status**)。待用户协助(clarify)/ 出错(error)**不触发**。
- `completed(AgentReply[])` —— **所有**智能体都完成(done/error、**没有任何 clarify 在等用户**)后,整批触发一次;
若有席位在等用户协助,本轮不发,待其答完重跑完成后再发。
- `saved(SavedTask)` / `update:taskId(string)` / `update:taskName(string)` / `update:brief(string)`
- `defineExpose`:`getResults()`(返回 `AgentResult[]`,含 status) / `getResult(id)` / `start()` / `reanalyze()` / `save()` / `cancel()` / `loadTask(id)`
---
@@ -160,16 +175,20 @@ ParallelAgentsPanel
| 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` |
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash, ask_clarification]`:
①禁写文件(并行不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸);
③禁 `ask_clarification`(**禁用用户协助**:本面板是并行**无人值守**扇出,没有让单席位中途打断、向用户
要澄清的交互位 —— 席位信息不足时自行做**合理假设**并写进正文交付,而非挂起等人)。
- **保留** 只读 / 检索工具 → 步骤条正常。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)+ **无人值守护栏**(「信息不足时
自行合理假设并注明,不要反问用户 / 不要挂起等待补充」)。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常(**用户协助开启**)。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
- `ask_clarification` 已被禁用,正常不再产生 `agent_clarification` 事件;`_clarification_from_messages`
仅作终态兜底(工具被禁后恒返回 `None`),不影响其他业务路由(圆桌 / 普通问答的澄清照常)。
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。
**前端澄清(用户协助)交互要点**(`useParallelRun` / `ClarificationCard` / `ParallelAgentsPanel`):
- 协作卡片**不再因 `submitting` 禁用**:整批仍在跑时也能立刻回答某个已澄清的席位。
- `rerunAgent` 放开了「整批 running 时禁止重跑」的限制(澄清是该席位合并流里的终态,不会再抢气泡),
只拦同一席位的并发重跑;收尾循环(`start` 的 finally)**跳过 busy 席位**,交给 `rerunAgent` 自己结算。
- 答澄清后**不立刻清空**正文/步骤:先移除卡片、置流式并标记 `rerunPending`,等重跑产出**第一条新事件**
时(`maybeStartRerun`)再清空替换,避免单选视图整列空白闪烁。
- 「单席位结算回传」`onAgentSettled`:**`clarify` 不回传**(待用户协助≠模型完成),等重跑出 `done/error` 再回传。
---
@@ -223,6 +242,7 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
| 改布局类 / 加新工具类 | `parallel-agents.css` 末尾的「工具类子集」补规则,或组件 scoped `<style>`(不要装 Tailwind) |
| 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) |
| 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 |
| 改自动下滑 / 浮动按钮行为 | `AutoScrollPane.vue`(`BOTTOM_TOLERANCE` 容差 / `onScroll` 暂停恢复逻辑)+ `.pa-autoscroll-btn` 样式 |
| 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` |
| 改鉴权方式 | `api.ts` 的 `resolveToken` / `loginByUsername` / `authedFetch` + `config.ts` 的 `TOKEN_PATH` |
@@ -232,9 +252,8 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
- **已禁用「用户协助」**:并行无人值守,后端把 `ask_clarification` 加入 `_EXCLUDED_TOOLS`,席位不再向用户要
澄清(信息不足时自行合理假设并在正文注明)。前端 `ClarificationCard.vue` / `onClarify` / `rerunAgent`
等协作链路代码保留作兜底,正常不触发;**仅影响本面板**,圆桌 / 普通问答的澄清不受影响。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume(当前已禁用,仅兜底保留)。
- **用户协助(澄清)已启用**:`ask_clarification` 保留;席位信息不足时可弹协作卡片。卡片任何时候都可点
(含整批仍在跑时),用户答完即重跑该席位;待协助(clarify)不算完成、不回传给宿主。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
+4 -4
View File
@@ -7,7 +7,7 @@
*/
import { reactive, ref } from "vue";
import { ParallelAgentsPanel } from "./parallel-agents";
import type { AgentInput, AgentResult } from "./parallel-agents";
import type { AgentInput, AgentReply } from "./parallel-agents";
import { DEFAULT_BASE_URL } from "./parallel-agents/config";
@@ -34,10 +34,10 @@ function onTaskId(id: string) {
cfg.taskId = id;
localStorage.setItem("pa.taskId", id);
}
function onAgentCompleted(result: AgentResult) {
console.log("[demo] agent-completed → 某智能体分析完:", result.name, result.status, result.content);
function onAgentCompleted(result: AgentReply) {
console.log("[demo] agent-completed → 某智能体分析完:", result.name, result.content);
}
function onCompleted(results: AgentResult[]) {
function onCompleted(results: AgentReply[]) {
console.log("[demo] completed → 全部分析完,各智能体返回内容:", results);
}
+1
View File
@@ -13,6 +13,7 @@ import { ParallelAgentsPanel } from "./parallel-agents";
export { ParallelAgentsPanel };
export type {
AgentInput,
AgentReply,
AgentResult,
Dialogue,
SavedTask,
@@ -0,0 +1,108 @@
<script setup lang="ts">
/**
* 自动下滑滚动窗格 —— 包住单个席位的消息内容,提供:
* ① 高度自适应:父元素不够高时,内容超出即在**本窗格内**出现滚动条(不撑破容器)。
* ② 流式自动下滑:模型输出(内容变高)时自动钉到底部。
* ③ 用户接管:用户向上滚动离开底部 → 暂停自动下滑;滚回底部 → 自动恢复。
* ④ 暂停且可滚动时,窗格底部居中浮出半透明「自动滚动」按钮,点击恢复并滚到底。
*
* **每个席位各渲染一个本组件实例**(父级 `v-for :key=agentId`),状态(autoScroll / 滚动位置 /
* 观察器)都在实例内,因此多个智能体之间的自动下滑**互不影响**。
*
* 设计要点:滚动容器(viewport)与浮动按钮是**兄弟**关系,按钮挂在不滚动的 relative 根上,
* 所以按钮不会跟着内容一起滚走。自动下滑一律用瞬时跳转(behavior:auto)——既能跟上流式速度,
* 又避免平滑滚动过程中的中间帧把 autoScroll 误判成「离开底部」。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
/** 判定「已在底部」的容差(px):避免亚像素/缩放导致永远差 1px 到不了底。 */
const BOTTOM_TOLERANCE = 24;
const viewport = ref<HTMLElement | null>(null);
const content = ref<HTMLElement | null>(null);
/** 是否处于「跟随底部」状态(true = 自动下滑开启)。 */
const autoScroll = ref(true);
/** 内容是否超出窗格(只有可滚动时浮动按钮才有意义)。 */
const canScroll = ref(false);
let ro: ResizeObserver | null = null;
function atBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_TOLERANCE;
}
function scrollToBottom(): void {
const el = viewport.value;
if (!el) return;
// 瞬时跳转:跟上流式、且不产生平滑滚动的中间帧(否则 onScroll 会误判离开底部)。
el.scrollTop = el.scrollHeight;
}
function refreshMetrics(): void {
const el = viewport.value;
canScroll.value = !!el && el.scrollHeight - el.clientHeight > BOTTOM_TOLERANCE;
}
/** 用户滚动:到底→跟随,离开底部→暂停。程序化跳到底后这里也会判为到底,故保持开启。 */
function onScroll(): void {
const el = viewport.value;
if (!el) return;
autoScroll.value = atBottom(el);
refreshMetrics();
}
/** 点击浮动按钮:恢复自动下滑并立即滚到底。 */
function resume(): void {
autoScroll.value = true;
scrollToBottom();
refreshMetrics();
}
const showButton = computed(() => canScroll.value && !autoScroll.value);
onMounted(() => {
// 首屏(含切到一个已在后台流式的席位):先钉到底部。
void nextTick(() => {
scrollToBottom();
refreshMetrics();
});
// 内容高度变化(流式追加正文 / 步骤条 / 表格渲染等)→ 跟随时钉底,并刷新可滚动态。
ro = new ResizeObserver(() => {
if (autoScroll.value) scrollToBottom();
refreshMetrics();
});
if (content.value) ro.observe(content.value);
if (viewport.value) ro.observe(viewport.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
ro = null;
});
</script>
<template>
<div class="pa-autoscroll">
<div ref="viewport" class="pa-autoscroll-view" @scroll.passive="onScroll">
<div ref="content">
<slot />
</div>
</div>
<button
v-if="showButton"
type="button"
class="pa-autoscroll-btn"
title="恢复自动滚动"
aria-label="恢复自动滚动"
@click="resume"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</svg>
<span>自动滚动</span>
</button>
</div>
</template>
@@ -24,7 +24,9 @@ function norm(opts: ClarOption[]): NormOption[] {
}
const options = computed(() => norm(props.payload.options));
const allowMultiple = computed(() => !!props.payload.allowMultiple);
// 产品约定:协作卡片的选项**默认多选**(勾选多个 + 自定义补充,再点「发送回答」),
// 不再受后端 allow_multiple 影响,避免单选「点击即发」误触。
const allowMultiple = computed(() => true);
const allowCustom = computed(() => props.payload.allowCustom !== false);
const deferSubmit = computed(() => allowCustom.value); // 允许自定义时不在单击即发,给机会补充
const showSubmit = computed(() => allowMultiple.value || allowCustom.value || options.value.length === 0);
@@ -44,7 +46,7 @@ function buildAnswer(ids: string[]): string | null {
}
function clickOption(id: string) {
if (props.submitting) return;
// 注意:不再因 submitting 禁止点击 —— 并行批次仍在跑(running)时也允许用户立刻回答澄清。
if (!allowMultiple.value) {
if (deferSubmit.value) {
sel.ids = [id];
@@ -58,7 +60,8 @@ function clickOption(id: string) {
const canSubmit = computed(() => sel.ids.length > 0 || sel.custom.trim().length > 0);
function submit() {
if (props.submitting || !canSubmit.value) return;
// 同上:不因 submitting 拦截,只要求至少选了/填了内容。
if (!canSubmit.value) return;
const ans = buildAnswer(sel.ids);
if (ans) emit("submit", ans);
}
@@ -93,7 +96,6 @@ function submit() {
v-for="opt in options"
:key="opt.id"
type="button"
:disabled="submitting"
class="flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition"
:class="
sel.ids.includes(opt.id)
@@ -116,7 +118,6 @@ function submit() {
<div v-if="allowCustom" class="mt-3 flex flex-col gap-2">
<textarea
v-model="sel.custom"
:disabled="submitting"
rows="2"
class="pa-input min-h-16 resize-none px-2.5 py-1.5 text-xs"
:placeholder="
@@ -134,10 +135,10 @@ function submit() {
<button
type="button"
class="pa-btn-primary h-8 rounded-xl px-4 text-xs font-bold"
:disabled="submitting || !canSubmit"
:disabled="!canSubmit"
@click="submit"
>
{{ submitting ? "发送中…" : "发送回答" }}
发送回答
</button>
</div>
</div>
@@ -12,7 +12,7 @@
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, ref, watch } from "vue";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "./types";
import type { AgentInput, AgentReply, AgentResult, Dialogue, SavedTask, SeatView } from "./types";
import { useParallelRun } from "./useParallelRun";
import {
createTask,
@@ -24,6 +24,7 @@ import {
import { DEFAULT_BASE_URL } from "./config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
import AutoScrollPane from "./AutoScrollPane.vue";
const props = withDefaults(
defineProps<{
@@ -56,10 +57,10 @@ const emit = defineEmits<{
(e: "update:taskName", name: string): void;
/** 回显已保存任务时回传简介。 */
(e: "update:brief", brief: string): void;
/** 单个智能体一分析完就回传它的结果(每个席位完成时各触发一次)。 */
(e: "agent-completed", result: AgentResult): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
/** 单个智能体**真正完成(done)**时回传它的结果对象 `{ agent_id, name, content }`(待协助/出错不发)。 */
(e: "agent-completed", result: AgentReply): void;
/** **所有**智能体都完成(done/error、无待协助)后,回传各智能体的返回结果数组。 */
(e: "completed", results: AgentReply[]): void;
}>();
const cfg = computed<ApiConfig>(() => ({
@@ -72,9 +73,15 @@ const cfg = computed<ApiConfig>(() => ({
// 单个智能体完成即回传它的结果(一分析完就给宿主)。
const run = useParallelRun({
onAgentSettled: (agentId) => {
const r = getResult(agentId);
if (r) emit("agent-completed", r);
onAgentSettled: (agentId, status) => {
// 只在**真正完成(done)**时回传单个结果;待用户协助(clarify)/出错(error)都不算完成,不回传,
// 免得宿主把「在等用户回答澄清」的中间态当成已出结果。等用户答完、该席位重跑出 done 时再回传。
if (status === "done") {
const r = getResult(agentId);
if (r) emit("agent-completed", toReply(r));
}
// 每有一个席位收尾就检查:是否**所有**席位都完成了 → 是则发一次批量结果。
maybeEmitCompleted();
},
});
const { dialogues, seats, running, busy } = run;
@@ -154,6 +161,21 @@ function getResults(): AgentResult[] {
function getResult(agentId: string): AgentResult | null {
return getResults().find((r) => r.agent_id === agentId) ?? null;
}
/** AgentResult → 回传给宿主的精简对象(智能体名称 + 模型返回结果),去掉 status。 */
function toReply(r: AgentResult): AgentReply {
return { agent_id: r.agent_id, name: r.name, content: r.content };
}
/** 是否所有席位都已收尾:done / error 都算「已返回」,只要还有 clarify(待协助)/ running / pending 就不算。 */
function allAgentsDone(): boolean {
const list = seats.value;
if (!list.length) return false;
return list.every((s) => s.status === "done" || s.status === "error");
}
/** 仅当**所有**智能体都完成时,才把全部结果作为批量 completed 发给宿主(待协助未答完不发)。 */
function maybeEmitCompleted() {
if (!allAgentsDone()) return;
emit("completed", getResults().map(toReply));
}
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />`
// 宿主自行渲染按钮,调用下面这些方法 / 读取状态:
// panelRef.value.reanalyze() // 开始 / 重新分析
@@ -188,8 +210,9 @@ async function onClarify(agentId: string, answer: string) {
brief: merged,
agentId,
model: model.value,
userNote: answer, // 在该席位历史里展示「我的补充」,协助前的内容不丢
});
emit("completed", getResults());
// 批量 completed 由 onAgentSettled→maybeEmitCompleted 在「全部完成」时统一发;这里不再直接发。
void autoSave(); // 重跑完也自动存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
@@ -206,8 +229,8 @@ async function onStart() {
agents: props.agents,
model: model.value,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
// 批量 completed 改由 onAgentSettled→maybeEmitCompleted 在「全部智能体完成」时统一发:
// 若有席位在等用户协助(clarify),本轮不发批量,待其答完重跑完成后再发。
void autoSave(); // 分析完自动保存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
@@ -324,25 +347,29 @@ onMounted(async () => {
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
</div>
<!-- 会话区:并行双列 -->
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<!-- 会话区:并行双列。本容器只负责限高(min-h-0 flex-1),不整体滚动;
滚动条与自动下滑下放到每个席位卡片内部的 AutoScrollPane(各智能体互不影响)。 -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden p-4">
<div
v-if="hasResults"
class="grid gap-4"
class="grid min-h-0 flex-1 gap-4 pa-rows-fill"
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<div
v-for="seat in visibleSeats"
:key="seat.agentId"
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
class="pa-surface-soft pa-border relative flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border"
>
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running || busy.has(seat.agentId)"
@clarify="onClarify"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
<AutoScrollPane v-if="dialogueById.get(seat.bubbleId)">
<div class="p-3">
<MessageBubble
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running || busy.has(seat.agentId)"
@clarify="onClarify"
/>
</div>
</AutoScrollPane>
<div v-else class="flex flex-1 items-center justify-center py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出…
</div>
</div>
@@ -14,6 +14,7 @@ import "./parallel-agents.css";
export { default as ParallelAgentsPanel } from "./ParallelAgentsPanel.vue";
export type {
AgentInput,
AgentReply,
AgentResult,
ClarificationPayload,
ClarOption,
@@ -30,6 +30,16 @@
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
/* 根容器布局:必须写在这里,不能只靠模板上的 .h-full/.flex/.flex-col 工具类 ——
* 那些工具类是后代选择器(.parallel-agents .flex),匹配不到「自身就带 .parallel-agents
* 类」的根元素,于是 height:100% / display:flex 在根上从不生效,整条高度链断裂、不出滚动条。
* 这里直接给根定死:撑满宿主给定的高度并作纵向 flex 容器(min-height:0 允许子项内部滚动)。 */
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
.parallel-agents *,
.parallel-agents *::before,
@@ -202,6 +212,58 @@
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* ── 自动下滑窗格(AutoScrollPane.vue)────────────────────────────────────────
* 根 = 不滚动的 relative 容器,靠 flex-1 填满席位卡片(卡片高度受会话区限制);
* viewport = 绝对定位铺满根,自己滚动 —— 内部高度不依赖逐层 flex 传导,只要卡片
* 有确定高度就一定能正确限高出滚动条。浮动按钮是 viewport 的兄弟,挂在不滚动的根上。 */
.parallel-agents .pa-autoscroll {
position: relative;
flex: 1 1 0%;
min-height: 0;
width: 100%;
}
.parallel-agents .pa-autoscroll-view {
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* 浮动「自动滚动」按钮:半透明、底部居中、悬浮在内容之上;不随内容滚走。 */
.parallel-agents .pa-autoscroll-btn {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
color: var(--pa-fg);
background: color-mix(in oklch, var(--pa-card) 70%, transparent);
border: 1px solid color-mix(in oklch, var(--pa-primary) 45%, var(--pa-border));
border-radius: 9999px;
cursor: pointer;
opacity: 0.82;
backdrop-filter: blur(6px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
transition: opacity 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
z-index: 5;
}
.parallel-agents .pa-autoscroll-btn:hover {
opacity: 1;
background: color-mix(in oklch, var(--pa-card) 88%, transparent);
}
.parallel-agents .pa-autoscroll-btn:active {
transform: translateX(-50%) translateY(1px);
}
.parallel-agents .pa-autoscroll-btn svg {
width: 14px;
height: 14px;
}
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
.parallel-agents .pa-md {
color: var(--pa-fg);
@@ -318,6 +380,8 @@
@media (min-width: 1024px) {
.parallel-agents .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 行等高填充容器:让席位卡片高度受限于会话区,从而卡片内部出现滚动条而非撑破容器 */
.parallel-agents .pa-rows-fill { grid-auto-rows: minmax(0, 1fr); }
/* position */
.parallel-agents .relative { position: relative; }
@@ -84,7 +84,7 @@ export type StreamEvent =
| { type: "agent_error"; agent_id: string; message: string }
| { type: "all_done" };
/** 单个智能体的返回结果(供宿主通过 getResults() / completed 事件读取)。 */
/** 单个智能体的返回结果(供宿主通过 getResults() 读取)。 */
export interface AgentResult {
agent_id: string;
name: string;
@@ -94,6 +94,18 @@ export interface AgentResult {
status: SeatStatus;
}
/**
* 回传给宿主的「已完成」结果(agent-completed / completed 事件用):只含**智能体名称 + 模型返回
* 结果**(agent_id 便于识别)。不带 status —— 因为只在真正完成(done)时才回传,本身即代表已完成。
*/
export interface AgentReply {
agent_id: string;
/** 智能体名称(显示名)。 */
name: string;
/** 模型返回结果(正文 Markdown)。 */
content: string;
}
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
export interface SavedTask {
id: string;
@@ -49,6 +49,16 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
opts.onAgentSettled?.(agentId, status);
}
// 「单席位重跑」(回答澄清后继续)要**保留**协助前的历史正文:把「旧正文 + 我的补充」作为
// 前缀(在 rerunAgent 里写进 d.content 并记到这里),新一轮输出接在后面。该席位收到带 content
// 的终态事件(done / clarification)时,把前缀拼回最终正文最前面,避免被后端终态正文覆盖掉历史。
const rerunPrefix = new Map<string, string>();
function applyRerunPrefix(agentId: string, content: string): string {
const pre = rerunPrefix.get(agentId);
rerunPrefix.delete(agentId);
return (pre ?? "") + content;
}
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
@@ -117,7 +127,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content; // 终态正文以后端为准
// 终态正文以后端为准;若是「回答澄清后的重跑」,把协助前的历史前缀拼回正文最前面。
if (ev.content) d.content = applyRerunPrefix(ev.agent_id, ev.content);
else rerunPrefix.delete(ev.agent_id);
d.streaming = false;
d.confidence = "已完成";
}
@@ -130,7 +142,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content;
// 同 done:重跑后再次澄清也要保留历史前缀。
if (ev.content) d.content = applyRerunPrefix(ev.agent_id, ev.content);
else rerunPrefix.delete(ev.agent_id);
d.clarification = {
question: ev.question,
clarificationType: ev.clarification_type,
@@ -150,6 +164,7 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
}
case "agent_error": {
flushPending();
rerunPrefix.delete(ev.agent_id); // 出错就丢弃前缀(正文已含历史+已流出的增量)
const d = findDialogue(ev.agent_id);
if (d) {
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
@@ -214,8 +229,10 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
ac.signal,
);
} finally {
// 收尾:任何仍在 streaming 的气泡都置完成态。
// 收尾:任何仍在 streaming 的气泡都置完成态。跳过正在「协助重跑」的席位(busy)——
// 它有自己独立的流,由 rerunAgent 的 finally 负责收尾,别被整批收尾误置完成。
for (const d of dialogues.value) {
if (busy.value.has(d.agentId)) continue;
if (d.streaming) {
d.streaming = false;
if (d.confidence === "并行执行中") d.confidence = "已完成";
@@ -223,7 +240,11 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
}
dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态,保留待协助/出错)。
for (const s of seats.value) settleAgent(s.agentId, settleStatusOf(s.status));
// 同样跳过 busy 席位:它的结算交给 rerunAgent,避免在重跑途中被提前判完成回传。
for (const s of seats.value) {
if (busy.value.has(s.agentId)) continue;
settleAgent(s.agentId, settleStatusOf(s.status));
}
running.value = false;
abortRef.value = null;
}
@@ -243,14 +264,23 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
*/
async function rerunAgent(
cfg: ApiConfig,
args: { taskName: string; brief: string; agentId: string; model?: string },
args: { taskName: string; brief: string; agentId: string; model?: string; userNote?: string },
): Promise<void> {
// 整批分析进行中不允许单独重跑(会和合并流抢同一气泡);同一席位也不能并发重跑。
if (running.value || busy.value.has(args.agentId)) return;
// 允许在整批仍在跑(running)时回答某个已澄清的席位:它在合并流里那一轮已经结束
// (澄清是该席位的终态),不会再抢同一气泡。这里只拦截同一席位的并发重跑。
if (busy.value.has(args.agentId)) return;
const d = dialogues.value.find((x) => x.agentId === args.agentId);
if (!d) return;
d.content = "";
d.steps = [];
// 保留协助前的历史:把「旧正文 + 我的补充」作为前缀立刻写进正文(不清空,避免历史丢失),
// 新一轮输出接在后面;终态事件回来时由 applyRerunPrefix 把前缀拼到最终正文最前面。
const prev = (d.content || "").trim();
const note = (args.userNote || "").trim();
const prefix = prev
? `${prev}\n\n---\n\n**🙋 我的补充:** ${note || "(已补充)"}\n\n---\n\n`
: "";
rerunPrefix.set(args.agentId, prefix);
d.content = prefix;
d.steps = []; // 步骤条按新一轮重新累计
d.clarification = undefined;
d.streaming = true;
d.confidence = "并行执行中";
@@ -313,6 +343,7 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
pending.clear();
bubbleByAgent.clear();
settled.clear();
rerunPrefix.clear();
dialogues.value = [];
seats.value = [];
}