代码提交
This commit is contained in:
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]'
|
||||
|
||||
+44
-34
@@ -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)
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user