feat(summarization): 非破坏性上下文压缩——保留原始对话仅在请求时瞬态压缩
重写 DeerFlowSummarizationMiddleware:将 stock before_model 的 RemoveMessage(REMOVE_ALL_MESSAGES) 改为 no-op,改在 wrap_model_call 中 瞬态压缩为 [summary, *recent],完整原始对话仍保留在 state 中,前端继续 渲染用户真实问答。summary 消息标记 hide_from_ui 且永不持久化,按 thread 缓存以避免每次模型调用都触发 LLM。前端 isHiddenFromUIMessage 额外隐藏旧 版本持久化的 name="summary" 消息。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
22222308a1
47 files changed
+2856
-1063
No files matched your search
@@ -5,6 +5,8 @@
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
# CMZS后端接口
|
||||
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# 佳来模型对话接口
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
@@ -61,8 +62,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
},
|
||||
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{
|
||||
id: "qa-zzjc",
|
||||
label: "zzjc支持",
|
||||
icon: Bot,
|
||||
path: "/page/strategy/agents/6bf/chats/new",
|
||||
},
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
@@ -209,10 +215,16 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{
|
||||
id: "mgmt-business-chain-mapping",
|
||||
label: "业务链条管理",
|
||||
label: "配置管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/business-chain-mapping",
|
||||
},
|
||||
{
|
||||
id: "mgmt-business-chains",
|
||||
label: "业务链条配置",
|
||||
icon: Layers,
|
||||
path: "/page/strategy/business-chains",
|
||||
},
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 业务链条管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
|
||||
* 配置管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
|
||||
*
|
||||
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
|
||||
* - 智能体来自 `/api/agents`
|
||||
@@ -129,7 +129,7 @@ export default function BusinessChainMappingPage() {
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
|
||||
业务链条管理
|
||||
配置管理
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
|
||||
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components
|
||||
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
@@ -174,6 +176,8 @@ export default function StrategyRoutes() {
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
|
||||
<Route path="business-chains" element={<BusinessChainListPage />} />
|
||||
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
|
||||
|
||||
<Route
|
||||
path="scheduled"
|
||||
|
||||
@@ -44,6 +44,7 @@ import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -328,6 +329,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="business-chains"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<BusinessChainListPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|
||||
<Route
|
||||
path="roundtable/chain-preview"
|
||||
|
||||
@@ -40,11 +40,30 @@ export interface BusinessChainListDialogProps {
|
||||
onConfigure: (chainId?: string) => void;
|
||||
}
|
||||
|
||||
export function BusinessChainListDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
export interface BusinessChainListBodyProps {
|
||||
/**
|
||||
* 是否激活(控制何时拉取列表):弹窗里传 `open`(打开才拉),
|
||||
* 独立页面里恒为 `true`。
|
||||
*/
|
||||
active: boolean;
|
||||
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
|
||||
onConfigure: (chainId?: string) => void;
|
||||
/** 列表为空时的「去创建」引导文案差异(弹窗说「点下方配置」,页面说「点右上配置」)。 */
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
|
||||
*
|
||||
* 从 `BusinessChainListDialog` 抽出,供弹窗与独立配置页(`BusinessChainListPage`)
|
||||
* 复用,保证两处的加载 / 发布 / 卡片渲染逻辑完全一致。外层负责标题栏与「配置」入口、
|
||||
* 以及撑出 `flex flex-col` 高度(本组件用 `flex-1` 占满剩余空间)。
|
||||
*/
|
||||
export function BusinessChainListBody({
|
||||
active,
|
||||
onConfigure,
|
||||
}: BusinessChainListDialogProps) {
|
||||
emptyHint,
|
||||
}: BusinessChainListBodyProps) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -65,9 +84,9 @@ export function BusinessChainListDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||||
// 每次激活 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
@@ -84,7 +103,7 @@ export function BusinessChainListDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, scope]);
|
||||
}, [active, scope]);
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
@@ -101,6 +120,73 @@ export function BusinessChainListDialog({
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载业务链条…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
加载业务链条失败:{error}
|
||||
</div>
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: emptyHint ??
|
||||
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{chains.map((chain) => (
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
publishing={publishingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessChainListDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfigure,
|
||||
}: BusinessChainListDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||
@@ -114,61 +200,7 @@ export function BusinessChainListDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载业务链条…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
加载业务链条失败:{error}
|
||||
</div>
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{chains.map((chain) => (
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
publishing={publishingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BusinessChainListBody active={open} onConfigure={onConfigure} />
|
||||
|
||||
<DialogFooter className="shrink-0 border-t pt-3 sm:justify-end">
|
||||
<Button onClick={() => onConfigure()} className="gap-1.5">
|
||||
@@ -246,22 +278,70 @@ function ChainRow({
|
||||
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
|
||||
)}
|
||||
|
||||
{/* 席位名 tag(按顺序,→ 箭头连接发言次序) */}
|
||||
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
|
||||
{chain.seats.map((seat, i) => (
|
||||
<Fragment key={seat.agent_id}>
|
||||
{i > 0 && <ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs font-medium",
|
||||
chainTagClasses(getAvatarTypeFor(seat.agent_id, i)),
|
||||
)}
|
||||
>
|
||||
{seat.name || seat.agent_id}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。 */}
|
||||
<ChainSeatsFlow chain={chain} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 链条席位编排流(体现「同层并行 · 层间串行」)─────────────────────────────
|
||||
|
||||
function ChainSeatsFlow({ chain }: { chain: BusinessChain }) {
|
||||
// 名称回退表:用落库快照(seats)解析 stage 里的 agent_id。
|
||||
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
|
||||
|
||||
// 分层来源:带 stages(分层编排)就按 stages 渲染(过滤空阶段);否则每个席位各成
|
||||
// 一段、顺序串行(线性链,与旧版展示一致)。
|
||||
const stages =
|
||||
chain.stages && chain.stages.length > 0
|
||||
? chain.stages.filter((s) => s.length > 0)
|
||||
: chain.seats.map((s) => [s.agent_id]);
|
||||
|
||||
// 跨阶段累加的全局序号 —— 决定头像配色,与编辑器 / 展平 seats 顺序对齐。
|
||||
const orderById = new Map<string, number>();
|
||||
let counter = 0;
|
||||
for (const stageIds of stages) {
|
||||
for (const id of stageIds) {
|
||||
orderById.set(id, counter);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const seatTag = (agentId: string) => {
|
||||
const seat = seatById.get(agentId);
|
||||
return (
|
||||
<span
|
||||
key={agentId}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs font-medium",
|
||||
chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0)),
|
||||
)}
|
||||
>
|
||||
{seat?.name || agentId}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
|
||||
{stages.map((stageIds, stageIdx) => (
|
||||
<Fragment key={stageIdx}>
|
||||
{stageIdx > 0 && (
|
||||
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
{stageIds.length > 1 ? (
|
||||
// 并行阶段:席位竖向并列在虚线框里,左上角标「并行 N」。
|
||||
<div className="relative flex flex-col gap-1 rounded-lg border border-dashed border-primary/40 bg-primary/[0.04] px-1.5 pb-1.5 pt-2.5">
|
||||
<span className="pointer-events-none absolute -top-2 left-1.5 rounded-full bg-card px-1.5 text-[10px] font-semibold leading-tight text-primary">
|
||||
并行 {stageIds.length}
|
||||
</span>
|
||||
{stageIds.map((id) => seatTag(id))}
|
||||
</div>
|
||||
) : (
|
||||
seatTag(stageIds[0])
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -289,6 +289,43 @@ function ChainCard({
|
||||
const deletedCount = chain.seats.length - survivingCount;
|
||||
const usable = survivingCount >= CHAIN_MIN_SEATS;
|
||||
|
||||
// ── 席位编排(体现「同层并行 · 层间串行」)─────────────────────────────
|
||||
// 名称回退表:实时 name(nameById)→ 落库快照(seat.name)→ agent_id。
|
||||
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
|
||||
// 带 stages(分层编排)按 stages 渲染(过滤空阶段);否则每席位各成一段、顺序串行。
|
||||
const stages =
|
||||
chain.stages && chain.stages.length > 0
|
||||
? chain.stages.filter((s) => s.length > 0)
|
||||
: chain.seats.map((s) => [s.agent_id]);
|
||||
// 跨阶段累加的全局序号 —— 决定头像配色,与展平 seats 顺序对齐。
|
||||
const orderById = new Map<string, number>();
|
||||
let seatOrder = 0;
|
||||
for (const stageIds of stages) {
|
||||
for (const id of stageIds) {
|
||||
orderById.set(id, seatOrder);
|
||||
seatOrder += 1;
|
||||
}
|
||||
}
|
||||
// 单个席位 tag:存活按配色,已删除置灰删除线(保留旧行为)。
|
||||
const seatTag = (agentId: string) => {
|
||||
const alive = isExisting(agentId);
|
||||
const label = nameById.get(agentId) ?? seatById.get(agentId)?.name ?? agentId;
|
||||
return (
|
||||
<span
|
||||
key={`tag-${agentId}`}
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
alive
|
||||
? chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0))
|
||||
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
|
||||
)}
|
||||
title={alive ? undefined : "该智能体已不存在"}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -322,36 +359,36 @@ function ChainCard({
|
||||
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">{chain.description}</div>
|
||||
)}
|
||||
|
||||
{/* 智能体名 tag(按顺序,← 箭头连接) */}
|
||||
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。
|
||||
不用 <Fragment>:dev 期 tagger 会往 Fragment 注入 data-lov-id 触发 React 警告,
|
||||
这里用带 key 的扁平数组(箭头 + 阶段),注入落到真实元素上即可。 */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{chain.seats.flatMap((seat, i) => {
|
||||
const alive = isExisting(seat.agent_id);
|
||||
const label = nameById.get(seat.agent_id) ?? seat.name ?? seat.agent_id;
|
||||
// 不用 <Fragment>:dev 期 tagger 会往 Fragment 注入 data-lov-id 触发 React 警告,
|
||||
// 这里改成带 key 的扁平数组(箭头 + tag),注入落到真实元素上即可。
|
||||
{stages.flatMap((stageIds, stageIdx) => {
|
||||
const nodes: ReactNode[] = [];
|
||||
if (i > 0) {
|
||||
if (stageIdx > 0) {
|
||||
nodes.push(
|
||||
<ArrowRightIcon
|
||||
key={`arrow-${seat.agent_id}`}
|
||||
key={`arrow-${stageIdx}`}
|
||||
className="size-3 shrink-0 text-muted-foreground/50"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
<span
|
||||
key={`tag-${seat.agent_id}`}
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
alive
|
||||
? chainTagClasses(getAvatarTypeFor(seat.agent_id, i))
|
||||
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
|
||||
)}
|
||||
title={alive ? undefined : "该智能体已不存在"}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
);
|
||||
if (stageIds.length > 1) {
|
||||
// 并行阶段:席位竖向并列在虚线框里,左上角标「并行 N」。
|
||||
nodes.push(
|
||||
<div
|
||||
key={`stage-${stageIdx}`}
|
||||
className="relative flex flex-col gap-1 rounded-lg border border-dashed border-primary/40 bg-primary/[0.04] px-1.5 pb-1.5 pt-2.5"
|
||||
>
|
||||
<span className="pointer-events-none absolute -top-2 left-1.5 rounded-full bg-card px-1.5 text-[10px] font-semibold leading-tight text-primary">
|
||||
并行 {stageIds.length}
|
||||
</span>
|
||||
{stageIds.map((id) => seatTag(id))}
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
nodes.push(seatTag(stageIds[0]));
|
||||
}
|
||||
return nodes;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -123,6 +123,12 @@ export interface UseStep1IntentReturn {
|
||||
clarifications: ClarificationPayload[],
|
||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||
) => void;
|
||||
/**
|
||||
* 用户在「当前意图摘要」卡里手动编辑后保存:直接覆盖 `intentReady`。下游(Step 2 seed
|
||||
* `buildInitialIntent`、左右侧栏摘要卡、step2Locked 判定)都读 `intentReady`,改这里即全局生效。
|
||||
* 持久化由调用方(页面)在更新后触发草稿保存。
|
||||
*/
|
||||
updateIntentReady: (next: IntentSummary) => void;
|
||||
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
||||
stopIntentStream: () => void;
|
||||
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
|
||||
@@ -568,6 +574,12 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setInteractiveMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
const updateIntentReady = useCallback((next: IntentSummary) => {
|
||||
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
|
||||
setIntentReady(next);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
@@ -582,6 +594,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
runStep1Init,
|
||||
sendIntentMessage,
|
||||
submitIntentClarifications,
|
||||
updateIntentReady,
|
||||
stopIntentStream,
|
||||
hydrateFromDraft,
|
||||
appendLocalMessage,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 业务链条配置页(独立页面版)。
|
||||
*
|
||||
* 把原本挂在圆桌页顶栏「业务链条」按钮上的 `BusinessChainListDialog` 弹窗内容,
|
||||
* 单独画成一个全屏页面:综合管理 → 业务链条配置 入口进来即是本页。
|
||||
*
|
||||
* - 列表主体复用 `BusinessChainListBody`(与弹窗同一份加载 / 发布 / 卡片逻辑)。
|
||||
* - 顶部「配置」按钮 = 进入编辑器空白新建;卡片「设置」= 进入编辑器并切到该链条。
|
||||
*
|
||||
* 路由 base 感知:用 `useRouteNavigate` 跳转,strategy / workspace 两套路由树下
|
||||
* 都能落到当前 base 的编辑器(`roundtable/chains`)。
|
||||
*/
|
||||
import { LayersIcon, SettingsIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouteNavigate } from "@/core/route-base";
|
||||
|
||||
import { BusinessChainListBody } from "@/roundtable-planning/components/BusinessChainListDialog";
|
||||
|
||||
export default function BusinessChainListPage() {
|
||||
const navigate = useRouteNavigate();
|
||||
|
||||
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
||||
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
||||
const goConfigure = (chainId?: string) => {
|
||||
navigate(
|
||||
chainId
|
||||
? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}`
|
||||
: "/page/workspace/roundtable/chains",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
{/* 顶栏:标题 + 描述 + 配置入口 */}
|
||||
<header className="shrink-0 border-b border-border bg-card px-6 py-4">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-base font-bold text-foreground">
|
||||
<LayersIcon className="size-5 text-primary" />
|
||||
业务链条配置
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
可复用的有序智能体编排。点击卡片「设置」可进入编辑器修改该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => goConfigure()} className="shrink-0 gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
配置
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 列表主体(与弹窗共用 BusinessChainListBody) */}
|
||||
<div className="mx-auto flex w-full min-h-0 max-w-3xl flex-1 flex-col px-6 py-4">
|
||||
<BusinessChainListBody
|
||||
active
|
||||
onConfigure={goConfigure}
|
||||
emptyHint="业务链条是可复用的有序智能体编排。点击右上角「配置」去创建第一条链条。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,12 +41,14 @@ import {
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
type SeatSelectionResult,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
@@ -499,6 +502,26 @@ export default function RoundtablePlanningPage() {
|
||||
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
|
||||
});
|
||||
|
||||
// ─── 用户手动编辑「当前意图摘要」并保存 ───────────────────────
|
||||
// 先 updateIntentReady 覆盖意图,再 bump 计数触发一次草稿保存。**必须用计数 + effect**
|
||||
// 而非直接调 handleManualSave:enqueueSave 闭包捕获的 getStep1Snapshot 读的是本次渲染的
|
||||
// step1.intentReady,同步调用会在 setState flush 前存进旧意图(踩坑);放到 effect 里,等
|
||||
// 重渲染后再保存,getStep1Snapshot 才读到新值(与 step2/step3 的计数触发器同构)。
|
||||
const [intentEditTick, setIntentEditTick] = useState(0);
|
||||
const handleSaveIntent = useCallback(
|
||||
(next: IntentSummary) => {
|
||||
step1.updateIntentReady(next);
|
||||
setIntentEditTick((t) => t + 1);
|
||||
},
|
||||
[step1],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (intentEditTick === 0) return;
|
||||
drafts.handleManualSave();
|
||||
// 仅在 tick 递增时保存;handleManualSave 取最新闭包(effect 跑在重渲染之后)。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intentEditTick]);
|
||||
|
||||
// ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
|
||||
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
|
||||
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
|
||||
@@ -1106,6 +1129,7 @@ export default function RoundtablePlanningPage() {
|
||||
intentReady={step1.intentReady}
|
||||
timelineEntries={step1.timelineEntries}
|
||||
triggerToast={triggerToast}
|
||||
onSaveIntent={handleSaveIntent}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -1763,6 +1787,148 @@ function ModelSelectorCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「当前意图摘要」编辑态:目标(单行/多行文本)、约束、关键假设(均为可增删的列表)。
|
||||
* 受控本地草稿,保存时过滤空行、要求目标非空,再上抛 {@link IntentSummary}。
|
||||
*/
|
||||
function IntentSummaryEditor({
|
||||
initial,
|
||||
title,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: IntentSummary;
|
||||
title: string;
|
||||
onSave: (next: IntentSummary) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [objective, setObjective] = useState(initial.objective ?? "");
|
||||
const [constraints, setConstraints] = useState<string[]>(
|
||||
initial.constraints?.length ? [...initial.constraints] : [""],
|
||||
);
|
||||
const [assumptions, setAssumptions] = useState<string[]>(
|
||||
initial.assumptions?.length ? [...initial.assumptions] : [],
|
||||
);
|
||||
|
||||
const objectiveInvalid = objective.trim().length === 0;
|
||||
|
||||
const updateRow = (
|
||||
setter: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
idx: number,
|
||||
value: string,
|
||||
) => setter((prev) => prev.map((v, i) => (i === idx ? value : v)));
|
||||
const removeRow = (setter: React.Dispatch<React.SetStateAction<string[]>>, idx: number) =>
|
||||
setter((prev) => prev.filter((_, i) => i !== idx));
|
||||
const addRow = (setter: React.Dispatch<React.SetStateAction<string[]>>) =>
|
||||
setter((prev) => [...prev, ""]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (objectiveInvalid) return;
|
||||
onSave({
|
||||
objective: objective.trim(),
|
||||
constraints: constraints.map((c) => c.trim()).filter(Boolean),
|
||||
assumptions: assumptions.map((a) => a.trim()).filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
const rowList = (
|
||||
label: string,
|
||||
rows: string[],
|
||||
setter: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
placeholder: string,
|
||||
) => (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={row}
|
||||
onChange={(e) => updateRow(setter, i, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[11.5px] text-slate-700 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(setter, i)}
|
||||
title="删除该项"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addRow(setter)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-500 hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-primary/30 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<IntentSectionIcon className="w-4 h-4" />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">
|
||||
编辑{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={objectiveInvalid}
|
||||
title="保存"
|
||||
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-2.5 text-[11px] font-bold text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
title="取消"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
|
||||
<textarea
|
||||
value={objective}
|
||||
onChange={(e) => setObjective(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="一句话说明本次要达成什么"
|
||||
className={cn(
|
||||
"w-full resize-y rounded-lg border bg-white px-2.5 py-1.5 text-[11.5px] leading-relaxed text-slate-700 focus:outline-none focus:ring-1",
|
||||
objectiveInvalid
|
||||
? "border-rose-300 focus:border-rose-400 focus:ring-rose-200/50"
|
||||
: "border-slate-200 focus:border-primary focus:ring-[var(--brand-200)]/50",
|
||||
)}
|
||||
/>
|
||||
{objectiveInvalid && (
|
||||
<p className="text-[10px] text-rose-500">核心目标不能为空</p>
|
||||
)}
|
||||
</div>
|
||||
{rowList("约束", constraints, setConstraints, "如:预算 / 时间 / 合规 / 范围")}
|
||||
{rowList("假设", assumptions, setAssumptions, "智能体为你做的假设(可选)")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntentSummaryCardProps {
|
||||
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||
title?: string;
|
||||
@@ -1773,6 +1939,11 @@ interface IntentSummaryCardProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
/**
|
||||
* 提供则显示「编辑」按钮,允许用户手动修改识别出的意图(目标 / 约束 / 假设)并保存。
|
||||
* 保存后回调上抛新意图,由页面覆盖 `intentReady` 并持久化草稿。不传 = 只读。
|
||||
*/
|
||||
onSave?: (next: IntentSummary) => void;
|
||||
}
|
||||
|
||||
function IntentSummaryCard({
|
||||
@@ -1780,9 +1951,25 @@ function IntentSummaryCard({
|
||||
title = "当前意图摘要",
|
||||
collapsible = false,
|
||||
sidebarToggle,
|
||||
onSave,
|
||||
}: IntentSummaryCardProps) {
|
||||
const [expanded, setExpanded] = useState(!collapsible);
|
||||
const showFullDetail = !collapsible || expanded;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
if (editing && intentReady) {
|
||||
return (
|
||||
<IntentSummaryEditor
|
||||
initial={intentReady}
|
||||
title={title}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSave={(next) => {
|
||||
onSave?.(next);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
@@ -1791,21 +1978,33 @@ function IntentSummaryCard({
|
||||
<IntentSectionIcon className="w-4 h-4" />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
|
||||
</div>
|
||||
{sidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sidebarToggle.onToggle}
|
||||
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
||||
aria-expanded={sidebarToggle.expanded}
|
||||
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{sidebarToggle.expanded ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onSave && intentReady && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
title="编辑意图"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{sidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sidebarToggle.onToggle}
|
||||
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
||||
aria-expanded={sidebarToggle.expanded}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{sidebarToggle.expanded ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
||||
@@ -2052,6 +2251,8 @@ interface RightSidebarProps {
|
||||
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
|
||||
triggerToast: (msg: string) => void;
|
||||
/** 用户编辑并保存意图的回调(透传给 IntentSummaryCard)。 */
|
||||
onSaveIntent: (next: IntentSummary) => void;
|
||||
}
|
||||
|
||||
function RightSidebar({
|
||||
@@ -2063,6 +2264,7 @@ function RightSidebar({
|
||||
intentReady,
|
||||
timelineEntries,
|
||||
triggerToast,
|
||||
onSaveIntent,
|
||||
}: RightSidebarProps) {
|
||||
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||
if (currentStep !== 1) return null;
|
||||
@@ -2078,7 +2280,7 @@ function RightSidebar({
|
||||
modelsLoading={modelsLoading}
|
||||
/>
|
||||
|
||||
<IntentSummaryCard intentReady={intentReady} />
|
||||
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
|
||||
|
||||
{/* 澄清记录 Timeline */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||
|
||||
@@ -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]'
|
||||
|
||||
+46
-36
@@ -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';
|
||||
@@ -170,7 +170,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
setShowAnalysisModal(false);
|
||||
};
|
||||
|
||||
// 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 单智能体分析:用「配置管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage
|
||||
// 自动发出并开始问答。未配置智能体则提示去配置。
|
||||
const handleSingleAgentAnalysis = async () => {
|
||||
@@ -184,7 +184,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
/* ignore → 下面按未配置处理 */
|
||||
}
|
||||
if (!agentId) {
|
||||
toast.error('该业务尚未在「业务链条管理」中配置单智能体');
|
||||
toast.error('该业务尚未在「配置管理」中配置单智能体');
|
||||
return;
|
||||
}
|
||||
const text = [
|
||||
@@ -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]'}`} />
|
||||
|
||||
+100
-101
@@ -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';
|
||||
@@ -170,7 +170,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
setShowAnalysisModal(false);
|
||||
};
|
||||
|
||||
// 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 单智能体分析:用「配置管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage
|
||||
// 自动发出并开始问答。未配置智能体则提示去配置。
|
||||
const handleSingleAgentAnalysis = async () => {
|
||||
@@ -184,7 +184,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
/* ignore → 下面按未配置处理 */
|
||||
}
|
||||
if (!agentId) {
|
||||
toast.error('该业务尚未在「业务链条管理」中配置单智能体');
|
||||
toast.error('该业务尚未在「配置管理」中配置单智能体');
|
||||
return;
|
||||
}
|
||||
const text = [
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { TaskListItem } from './TaskManagement';
|
||||
|
||||
/**
|
||||
* 任务看板假数据 —— 用于在没有后端 `/task/cop-tasks` / `/three/cop-three-tasks`
|
||||
* 接口时,给「任务分析」「三情分析」两个页面提供演示数据。
|
||||
*
|
||||
* 用法:把组件里的 `await service.post(url, payload)` 换成
|
||||
* `await mockCopTasks('task' | 'sentiment', taskType, payload)`
|
||||
* 返回结构与真实接口保持一致:`{ data: { records, total } }`。
|
||||
*/
|
||||
|
||||
export type MockColumnType = 'unanalyzed' | 'running' | 'completed';
|
||||
export type MockDataset = 'task' | 'sentiment';
|
||||
|
||||
interface MockTaskSeed {
|
||||
taskName: string;
|
||||
overview: string;
|
||||
taskDirection: string;
|
||||
taskLevel: number; // 1 一般 / 2 紧急 / 3 加急
|
||||
taskType: number; // 1 单任务 / 2 场景 / 3 会商 / 4 多智能体 / 5 检索
|
||||
}
|
||||
|
||||
/** 列 → 状态码,与组件内 taskStatusMap 对齐。 */
|
||||
const COLUMN_STATUS: Record<MockColumnType, number> = {
|
||||
unanalyzed: 0,
|
||||
running: 20,
|
||||
completed: 30,
|
||||
};
|
||||
|
||||
/** 生成一条完整的 TaskListItem,仅覆盖有意义的字段,其余给安全默认值。 */
|
||||
function makeTask(
|
||||
id: number,
|
||||
column: MockColumnType,
|
||||
seed: MockTaskSeed,
|
||||
createTime: string,
|
||||
updateTime: string,
|
||||
): TaskListItem {
|
||||
return {
|
||||
createBy: 1001,
|
||||
createTime,
|
||||
deptId: null,
|
||||
id,
|
||||
isTop: 0,
|
||||
issuingDept: '战略规划处',
|
||||
keywords: null,
|
||||
overview: seed.overview,
|
||||
planTaskTop: 0,
|
||||
platform: null,
|
||||
ptaskId: null,
|
||||
recommendedBasis: null,
|
||||
scene: '',
|
||||
sourceType: 1,
|
||||
step: null,
|
||||
taskContent: seed.overview,
|
||||
taskDirection: seed.taskDirection,
|
||||
taskEndDate: '2026-06-30 18:00:00',
|
||||
taskLevel: seed.taskLevel,
|
||||
taskName: seed.taskName,
|
||||
taskNo: `T-${String(id).padStart(6, '0')}`,
|
||||
taskStartDate: '2026-06-01 09:00:00',
|
||||
taskStatus: COLUMN_STATUS[column],
|
||||
taskSuperiorFlag: 0,
|
||||
taskType: seed.taskType,
|
||||
column,
|
||||
updateBy: 1001,
|
||||
updateTime,
|
||||
endCount: column === 'completed' ? 1 : 0,
|
||||
sumAll: 1,
|
||||
progress: column === 'completed' ? 100 : column === 'running' ? 60 : 0,
|
||||
taskStatusData: null,
|
||||
} as TaskListItem;
|
||||
}
|
||||
|
||||
/** 「任务分析」页(业务码 6BF)数据种子。 */
|
||||
const TASK_SEEDS: Record<MockColumnType, MockTaskSeed[]> = {
|
||||
unanalyzed: [
|
||||
{
|
||||
taskName: '夏季文旅消费季传播任务分析',
|
||||
overview:
|
||||
'围绕"夏季文旅消费季"主题,梳理传播目标、核心受众与潜在策略阻力,输出可执行的任务拆解建议。',
|
||||
taskDirection: '王磊',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '新能源补贴政策解读专题',
|
||||
overview:
|
||||
'针对新一轮新能源汽车补贴政策,分析公众关注焦点与误读风险点,形成政策解读传播要点清单。',
|
||||
taskDirection: '李婷',
|
||||
taskLevel: 1,
|
||||
taskType: 5,
|
||||
},
|
||||
{
|
||||
taskName: '城市更新片区舆论引导任务',
|
||||
overview:
|
||||
'对老城区改造涉及的拆迁、安置等敏感议题进行任务目标拆解,识别需要重点回应的群体诉求。',
|
||||
taskDirection: '张伟',
|
||||
taskLevel: 3,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '高校毕业季就业服务宣传',
|
||||
overview:
|
||||
'梳理面向应届毕业生的就业服务政策传播任务,明确分阶段触达节奏与渠道组合。',
|
||||
taskDirection: '赵敏',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '防汛应急科普传播任务',
|
||||
overview:
|
||||
'汛期来临前,分析防汛减灾科普内容的传播目标与受众分层,制定预警信息发布预案。',
|
||||
taskDirection: '陈昊',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '乡村振兴成果展示任务',
|
||||
overview:
|
||||
'围绕本地乡村振兴典型案例,拆解成果展示传播任务,确定故事化表达与素材采集方向。',
|
||||
taskDirection: '周琳',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
],
|
||||
running: [
|
||||
{
|
||||
taskName: '社区养老服务满意度专题分析',
|
||||
overview:
|
||||
'正在对社区居家养老服务的群众反馈进行任务分析,重点判断受众诉求与服务短板。',
|
||||
taskDirection: '孙强',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '中小企业纾困政策落地评估',
|
||||
overview:
|
||||
'多智能体协同分析纾困政策落地过程中的执行阻力与企业关切,形成阶段性研判。',
|
||||
taskDirection: '吴静',
|
||||
taskLevel: 3,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '城市轨道交通延误舆情研判',
|
||||
overview:
|
||||
'针对近期地铁延误事件,正在拆解舆论传播路径与关键节点,评估引导策略有效性。',
|
||||
taskDirection: '郑凯',
|
||||
taskLevel: 2,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '食品安全抽检结果发布任务',
|
||||
overview:
|
||||
'正在分析食品安全抽检通报的发布口径与受众反应,优化信息透明度与安抚策略。',
|
||||
taskDirection: '冯雪',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
taskName: '春节文化活动传播复盘',
|
||||
overview:
|
||||
'已完成春节系列文化活动的传播任务分析,沉淀受众画像与高互动内容规律。',
|
||||
taskDirection: '黄涛',
|
||||
taskLevel: 1,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '医保改革政策宣传任务',
|
||||
overview:
|
||||
'已完成医保改革政策的传播目标拆解与受众分层,输出分群体沟通话术。',
|
||||
taskDirection: '宋佳',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '城市营商环境优化宣传',
|
||||
overview:
|
||||
'已完成营商环境优化举措的任务分析,明确企业端与公众端的差异化传播重点。',
|
||||
taskDirection: '高鹏',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '老旧小区加装电梯议题分析',
|
||||
overview:
|
||||
'已完成加装电梯涉及的邻里诉求与阻力识别,形成多方协商沟通建议。',
|
||||
taskDirection: '何丽',
|
||||
taskLevel: 2,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '生态环保主题宣传周复盘',
|
||||
overview:
|
||||
'已完成环保宣传周的传播效果分析,总结渠道贡献度与可复用素材。',
|
||||
taskDirection: '罗勇',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '青年人才引进政策解读',
|
||||
overview:
|
||||
'已完成人才引进政策的受众判断与策略阻力分析,输出落地传播节奏表。',
|
||||
taskDirection: '谭芳',
|
||||
taskLevel: 1,
|
||||
taskType: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 「三情分析」页(业务码 3Q)数据种子。 */
|
||||
const SENTIMENT_SEEDS: Record<MockColumnType, MockTaskSeed[]> = {
|
||||
unanalyzed: [
|
||||
{
|
||||
taskName: '暑期旅游热点情绪监测',
|
||||
overview:
|
||||
'对暑期热门景区相关讨论进行情绪与倾向分析,识别游客吐槽集中点与正向口碑话题。',
|
||||
taskDirection: '王磊',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '电价调整公众情绪研判',
|
||||
overview:
|
||||
'围绕居民阶梯电价调整,分析公众情绪走向与主要担忧,判断需要重点疏导的群体。',
|
||||
taskDirection: '李婷',
|
||||
taskLevel: 3,
|
||||
taskType: 5,
|
||||
},
|
||||
{
|
||||
taskName: '校园周边交通安全舆情',
|
||||
overview:
|
||||
'对校园周边交通安全话题进行三情分析,梳理家长群体情绪与诉求强度。',
|
||||
taskDirection: '张伟',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '景区门票预约制讨论分析',
|
||||
overview:
|
||||
'分析门票预约制相关讨论的情绪分布,识别便利性与公平性两类核心关切。',
|
||||
taskDirection: '赵敏',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '夜间经济噪音投诉情绪',
|
||||
overview:
|
||||
'对夜市、夜间演出引发的噪音投诉进行情绪分析,区分居民与商户的不同立场。',
|
||||
taskDirection: '陈昊',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
],
|
||||
running: [
|
||||
{
|
||||
taskName: '台风预警信息传播情绪追踪',
|
||||
overview:
|
||||
'正在分析台风预警发布后的公众情绪变化,评估恐慌情绪与配合度的动态走势。',
|
||||
taskDirection: '孙强',
|
||||
taskLevel: 3,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '学区划分调整舆论情绪',
|
||||
overview:
|
||||
'多智能体协同追踪学区调整话题的情绪热度与对立观点,研判潜在升级风险。',
|
||||
taskDirection: '吴静',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '网络热点事件情绪态势',
|
||||
overview:
|
||||
'正在对近期网络热点事件进行三情分析,识别情绪峰值节点与关键意见领袖。',
|
||||
taskDirection: '郑凯',
|
||||
taskLevel: 2,
|
||||
taskType: 3,
|
||||
},
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
taskName: '春运出行体验情绪复盘',
|
||||
overview:
|
||||
'已完成春运期间出行体验的情绪与倾向分析,沉淀满意度变化与高频抱怨主题。',
|
||||
taskDirection: '黄涛',
|
||||
taskLevel: 1,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '惠民政策落地情绪评估',
|
||||
overview:
|
||||
'已完成惠民政策落地后的公众情绪评估,输出正向反馈与改进诉求清单。',
|
||||
taskDirection: '宋佳',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '城市内涝应对情绪分析',
|
||||
overview:
|
||||
'已完成强降雨内涝事件的三情分析,总结情绪演化曲线与有效安抚措施。',
|
||||
taskDirection: '高鹏',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '景区限流措施满意度分析',
|
||||
overview:
|
||||
'已完成节假日景区限流措施的情绪分析,明确游客接受度与体验改进方向。',
|
||||
taskDirection: '何丽',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '公共交通票价听证情绪',
|
||||
overview:
|
||||
'已完成票价听证会相关讨论的情绪与立场分析,输出分群体沟通建议。',
|
||||
taskDirection: '罗勇',
|
||||
taskLevel: 1,
|
||||
taskType: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SEEDS: Record<MockDataset, Record<MockColumnType, MockTaskSeed[]>> = {
|
||||
task: TASK_SEEDS,
|
||||
sentiment: SENTIMENT_SEEDS,
|
||||
};
|
||||
|
||||
/** 给同一列里的卡片生成错落有致的时间,便于「按时间倒序」展示。 */
|
||||
function buildTimes(index: number): { createTime: string; updateTime: string } {
|
||||
const day = String(28 - index).padStart(2, '0');
|
||||
const hour = String(9 + (index % 8)).padStart(2, '0');
|
||||
return {
|
||||
createTime: `2026-05-${day} ${hour}:15:00`,
|
||||
updateTime: `2026-06-0${(index % 9) + 1} ${hour}:40:00`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据集 → 列 → 完整任务列表(按 id 区分,避免两页 key 冲突)。 */
|
||||
function buildPool(dataset: MockDataset, column: MockColumnType): TaskListItem[] {
|
||||
const base = dataset === 'task' ? 10000 : 20000;
|
||||
const columnOffset = column === 'unanalyzed' ? 0 : column === 'running' ? 300 : 600;
|
||||
return SEEDS[dataset][column].map((seed, index) => {
|
||||
const { createTime, updateTime } = buildTimes(index);
|
||||
return makeTask(base + columnOffset + index, column, seed, createTime, updateTime);
|
||||
});
|
||||
}
|
||||
|
||||
interface MockCopTasksPayload {
|
||||
content?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
taskStatus?: number[];
|
||||
}
|
||||
|
||||
/** 根据请求里的 taskStatus 数组反推当前查询的是哪一列。 */
|
||||
function resolveColumn(taskStatus?: number[]): MockColumnType {
|
||||
const list = Array.isArray(taskStatus) ? taskStatus : [];
|
||||
if (list.includes(20)) return 'running';
|
||||
if (list.includes(0) || list.includes(10)) return 'unanalyzed';
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 `/task/cop-tasks` / `/three/cop-three-tasks` 接口的 body:
|
||||
* 按列返回分页后的假数据,支持按 `content` 对任务名/摘要做模糊过滤。
|
||||
*/
|
||||
function buildCopTasksBody(dataset: MockDataset, payload: MockCopTasksPayload) {
|
||||
const { content = '', pageNum = 1, pageSize = 10, taskStatus } = payload;
|
||||
const column = resolveColumn(taskStatus);
|
||||
|
||||
let pool = buildPool(dataset, column);
|
||||
const keyword = content.trim();
|
||||
if (keyword) {
|
||||
pool = pool.filter(
|
||||
(item) =>
|
||||
item.taskName.includes(keyword) || item.overview.includes(keyword),
|
||||
);
|
||||
}
|
||||
|
||||
const total = pool.length;
|
||||
const start = (pageNum - 1) * pageSize;
|
||||
const records = pool.slice(start, start + pageSize);
|
||||
|
||||
return { code: 0, message: 'ok', data: { records, total } };
|
||||
}
|
||||
|
||||
/** 命中本 mock 的接口路径 → 对应数据集。 */
|
||||
const MOCK_ROUTES: Array<{ match: string; dataset: MockDataset }> = [
|
||||
{ match: '/three/cop-three-tasks', dataset: 'sentiment' },
|
||||
{ match: '/task/cop-tasks', dataset: 'task' },
|
||||
];
|
||||
|
||||
/** 判断某个请求 url 是否应走假数据。 */
|
||||
export function matchTaskBoardMock(url?: string): MockDataset | null {
|
||||
if (!url) return null;
|
||||
const hit = MOCK_ROUTES.find((route) => url.includes(route.match));
|
||||
return hit ? hit.dataset : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时假数据 axios adapter:命中任务看板接口时直接返回 mock body,不发真实请求。
|
||||
* 仅用于演示,撤掉假数据时删除 request.ts 中对它的引用即可。
|
||||
*/
|
||||
export function taskBoardMockAdapter(config: {
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
}): Promise<{ data: unknown; status: number; statusText: string; headers: Record<string, unknown>; config: unknown }> {
|
||||
const dataset = matchTaskBoardMock(config.url) ?? 'task';
|
||||
|
||||
let payload: MockCopTasksPayload = {};
|
||||
try {
|
||||
payload =
|
||||
typeof config.data === 'string'
|
||||
? JSON.parse(config.data)
|
||||
: ((config.data as MockCopTasksPayload) ?? {});
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const body = buildCopTasksBody(dataset, payload);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 模拟网络延迟,让 Loading 态可见。
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: body,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
}, 280);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import axios from 'axios'
|
||||
// === 临时假数据(任务分析 / 三情分析看板):撤掉时删除本 import 及下方 request 拦截器中的对应块即可 ===
|
||||
import { matchTaskBoardMock, taskBoardMockAdapter } from '@/strategy-components/components/task-components/task-mock-data'
|
||||
// axios.interceptors
|
||||
const service = axios.create({
|
||||
// baseURL: '', // url = base url + request url
|
||||
@@ -31,6 +33,10 @@ service.interceptors.response.use(
|
||||
)
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// === 临时假数据:任务看板两个接口直接走本地 mock,不发真实请求 ===
|
||||
if (matchTaskBoardMock(config.url)) {
|
||||
config.adapter = taskBoardMockAdapter as any;
|
||||
}
|
||||
// let token: any = localStorage.getItem('token');
|
||||
config.headers['Authorization'] = `Bearer admin`;
|
||||
// if (token) {
|
||||
|
||||
@@ -70,6 +70,14 @@ class TaskUpdateRequest(BaseModel):
|
||||
messages: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class TaskEnsureRequest(BaseModel):
|
||||
"""get-or-create:宿主传的 taskId 不固定,新 id 直接**初始化**一条空记录返回(不 404)。"""
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
title: str = Field(default="", max_length=512)
|
||||
brief: str | None = None
|
||||
agents: list[TaskAgentSchema] = []
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -115,6 +123,30 @@ async def create_task(request: Request, body: TaskCreateRequest) -> TaskResponse
|
||||
return TaskResponse(**row)
|
||||
|
||||
|
||||
@router.post("/ensure", response_model=TaskResponse)
|
||||
async def ensure_task(request: Request, body: TaskEnsureRequest) -> TaskResponse:
|
||||
"""存在则返回(含 messages 供回显);不存在则用该 id **初始化**一条空记录返回。
|
||||
|
||||
宿主传的 taskId 不固定:新 id 不再 404,而是即时建好、可直接回显/保存。幂等。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
existing = await store.get_task(body.id, user_id)
|
||||
if existing is not None:
|
||||
return TaskResponse(**existing)
|
||||
row = await store.create_task(
|
||||
user_id,
|
||||
{
|
||||
"id": body.id,
|
||||
"title": body.title.strip(),
|
||||
"brief": body.brief,
|
||||
"agents": [a.model_dump() for a in body.agents],
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
return TaskResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(request: Request, task_id: str) -> TaskResponse:
|
||||
store = _get_store(request)
|
||||
|
||||
@@ -276,6 +276,13 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -25,11 +25,23 @@ pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-a
|
||||
|
||||
## 嵌入其它 Vue 项目
|
||||
|
||||
两种方式(详见 [docs/07-使用说明.md](./docs/07-使用说明.md)):
|
||||
|
||||
**A. 按包引入**(已 `build:lib`):
|
||||
|
||||
```ts
|
||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
||||
import "parallel-agents-vue/style.css";
|
||||
```
|
||||
|
||||
**B. 直接拷源码**(适合要改源码的人):把整个 **`src/parallel-agents/` 文件夹**拷进你的项目 `src/` 下,
|
||||
样式由其 `index.ts` 自动引入,**无需 Tailwind / 任何 CSS 框架**:
|
||||
|
||||
```ts
|
||||
import { ParallelAgentsPanel } from "@/parallel-agents"; // 或 "./parallel-agents"
|
||||
```
|
||||
> 仅需 `vue` 与 `markdown-it` 两个运行依赖。
|
||||
|
||||
```vue
|
||||
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
|
||||
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
|
||||
@@ -76,4 +88,5 @@ import "parallel-agents-vue/style.css";
|
||||
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
|
||||
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
|
||||
|
||||
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。
|
||||
> 样式作用域全部包在根类 `.parallel-agents` 下,**不依赖 Tailwind**(布局类是面板自带的作用域工具类子集),
|
||||
> 不会污染宿主全局样式。
|
||||
@@ -84,15 +84,15 @@ pnpm build:lib
|
||||
|
||||
## PA-EMBED-06 · 不污染宿主全局样式(P0)
|
||||
|
||||
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。
|
||||
**目的**:核对面板**不依赖 Tailwind**、不重置宿主全局标签样式(无任何裸标签选择器 / reset)。
|
||||
|
||||
**操作步骤**
|
||||
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
|
||||
1. 在一个有自己样式、且**没装 Tailwind** 的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
|
||||
|
||||
**预期结果**
|
||||
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。
|
||||
- 面板内部样式正常(不依赖宿主提供 Tailwind)。
|
||||
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
|
||||
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(面板样式无全局 reset、无裸标签规则)。
|
||||
- 面板内部样式正常(**不需要宿主提供 Tailwind**;布局类是面板自带的作用域工具类子集)。
|
||||
- 所有规则都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
|
||||
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
|
||||
|
||||
> **⚠️ 契约变更(纯展示版)**:面板**不再自带顶部表单与按钮**。
|
||||
> - **任务名称 / 简介 / 模型** 全部由宿主通过 props 传入(`taskName` / `brief` 支持 `v-model`);
|
||||
> - **「重新分析 / 保存 / 停止」按钮由宿主自己渲染**,通过模板 ref 调用面板 `defineExpose` 出的
|
||||
> `reanalyze()` / `save()` / `cancel()` 等方法(见 [§六·二](#六二暴露的方法与状态模板-ref));
|
||||
> - 面板本体只展示「席位状态条 + 并行会话 + 错误提示」。
|
||||
>
|
||||
> 完整的宿主参考实现见仓库 `src/App.vue`(弹窗里宿主自渲染工具条 + 调用 ref 方法)。
|
||||
|
||||
---
|
||||
|
||||
## 一、要引入的东西(就这两样)
|
||||
@@ -12,6 +20,8 @@
|
||||
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
|
||||
|
||||
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
|
||||
> **无需 Tailwind 或任何 CSS 框架**:布局样式已内置为 `parallel-agents.css` 末尾的「最小工具类子集」
|
||||
> (全部作用域在 `.parallel-agents` 下,不污染宿主)。宿主项目**不用**装 / 配 Tailwind。
|
||||
|
||||
---
|
||||
|
||||
@@ -33,21 +43,32 @@
|
||||
import "parallel-agents-vue/style.css";
|
||||
```
|
||||
|
||||
### 方式 B:直接拷贝源码(无需发包)
|
||||
### 方式 B:直接拷贝源码(无需发包,**推荐给需要改源码的人**)
|
||||
|
||||
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` +
|
||||
`styles/parallel-agents.css` 即可:
|
||||
所有实现已收进**一个自包含文件夹** `src/parallel-agents/`。把**整个文件夹**拷进宿主项目的 `src/`
|
||||
下即可,内部全是相对路径引用、不依赖任何构建别名:
|
||||
|
||||
```
|
||||
src/
|
||||
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue
|
||||
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue
|
||||
composables/ useParallelRun.ts
|
||||
lib/ avatar-constants.ts
|
||||
styles/ parallel-agents.css
|
||||
api.ts · config.ts · types.ts
|
||||
src/parallel-agents/ ← 整个文件夹拷走即用
|
||||
index.ts 入口:自动引样式 + 导出组件/类型
|
||||
ParallelAgentsPanel.vue 主组件
|
||||
ParallelSeatStrip.vue · MessageBubble.vue · MessageStepsCard.vue · StepIcon.vue
|
||||
ClarificationCard.vue · MarkdownContent.vue · Avatar.vue (子组件)
|
||||
useParallelRun.ts 并行运行编排
|
||||
api.ts · types.ts · config.ts · avatar-constants.ts · step-display.ts
|
||||
parallel-agents.css 作用域样式(深色 token + 语义类 + 工具类子集,无 Tailwind)
|
||||
README.md 本文件夹的速用说明
|
||||
```
|
||||
|
||||
引入(样式由 `index.ts` 自动引入,无需手动 import css):
|
||||
|
||||
```ts
|
||||
import { ParallelAgentsPanel } from "@/parallel-agents";
|
||||
// 或相对路径: import { ParallelAgentsPanel } from "./parallel-agents";
|
||||
```
|
||||
|
||||
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
|
||||
**不需要 Tailwind**。
|
||||
|
||||
---
|
||||
|
||||
@@ -55,8 +76,11 @@ src/
|
||||
|
||||
### 局部引入(推荐)
|
||||
|
||||
宿主持有任务名 / 简介 / 模型,并自渲染按钮调用面板暴露的方法:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
||||
import "parallel-agents-vue/style.css";
|
||||
|
||||
@@ -64,15 +88,43 @@ const agents = [
|
||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
||||
];
|
||||
|
||||
// 宿主持有这三项(面板不再渲染输入框)
|
||||
const taskName = ref("");
|
||||
const brief = ref("");
|
||||
const model = ref("");
|
||||
|
||||
// 模板 ref:拿到面板暴露的方法 / 状态
|
||||
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="height: 80vh">
|
||||
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
|
||||
<div style="display: flex; flex-direction: column; height: 80vh">
|
||||
<!-- 宿主自己的工具条 -->
|
||||
<input v-model="taskName" placeholder="任务名称" />
|
||||
<textarea v-model="brief" placeholder="任务简介" />
|
||||
<button :disabled="!panel?.canStart" @click="panel?.reanalyze()">
|
||||
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
|
||||
</button>
|
||||
<button :disabled="panel?.saving || !panel?.hasResults" @click="panel?.save()">保存</button>
|
||||
|
||||
<!-- 面板:纯展示,需父容器给高度 -->
|
||||
<ParallelAgentsPanel
|
||||
ref="panel"
|
||||
style="flex: 1; min-height: 0"
|
||||
:username="'demo'"
|
||||
:agents="agents"
|
||||
v-model:task-name="taskName"
|
||||
v-model:brief="brief"
|
||||
:model="model"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> 面板暴露出的 `running` / `saving` / `hasResults` / `canStart` 等是 ref,**在组件实例上会自动解包**,
|
||||
> 模板里直接 `panel?.canStart` 读,**无需 `.value`**。
|
||||
|
||||
### 全局注册(可选)
|
||||
|
||||
```ts
|
||||
@@ -90,8 +142,20 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
|
||||
|
||||
```vue
|
||||
<YourDrawer v-model="open" size="large">
|
||||
<div style="height: 86vh">
|
||||
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
|
||||
<div style="display: flex; flex-direction: column; height: 86vh">
|
||||
<!-- 宿主工具条:任务名/简介/模型 + 按钮,由你自己渲染 -->
|
||||
<MyToolbar v-model:task-name="taskName" v-model:brief="brief"
|
||||
@start="panel?.reanalyze()" @save="panel?.save()" />
|
||||
<ParallelAgentsPanel
|
||||
ref="panel"
|
||||
style="flex: 1; min-height: 0"
|
||||
:username="currentUsername"
|
||||
:agents="agents"
|
||||
v-model:task-name="taskName"
|
||||
v-model:brief="brief"
|
||||
:model="model"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</YourDrawer>
|
||||
```
|
||||
@@ -122,14 +186,66 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
|
||||
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
|
||||
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
|
||||
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
|
||||
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
|
||||
| `taskName` | `string` | | **任务名称(宿主持有)**,支持 `v-model:task-name` |
|
||||
| `brief` | `string` | | **任务简介(宿主持有)**,支持 `v-model:brief` |
|
||||
| `model` | `string` | | 模型名(宿主持有;空 = 默认 / 后端回退) |
|
||||
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
|
||||
|
||||
> `taskName` / `brief` 用 `v-model` 是为了**回显**:加载已保存任务时,面板会通过 `update:taskName` /
|
||||
> `update:brief` 把存档里的标题 / 简介回传给宿主,宿主据此同步自己的输入框。
|
||||
|
||||
`AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出:
|
||||
`import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。
|
||||
|
||||
---
|
||||
|
||||
## 六·二、暴露的方法与状态(模板 ref)
|
||||
|
||||
面板通过 `defineExpose` 暴露下列**方法**与**状态**,供宿主用模板 ref 调用 / 读取。
|
||||
拿 ref:`const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null)`,模板 `<ParallelAgentsPanel ref="panel" .../>`。
|
||||
|
||||
**方法**
|
||||
|
||||
| 方法 | 等价于 | 说明 |
|
||||
| --------------------- | ----------------- | -------------------------------------------- |
|
||||
| `reanalyze()` | 旧「重新分析」按钮 | 用当前 `taskName`/`brief`/`model` 开始 / 重跑一轮 |
|
||||
| `start()` | 同 `reanalyze()` | 别名,语义同上 |
|
||||
| `save()` | 旧「保存」按钮 | 保存 / 更新当前聊天记录(按 `taskId` upsert)|
|
||||
| `cancel()` | 旧「停止」按钮 | 中止本轮并行运行 |
|
||||
| `loadTask(id)` | 旧「历史」选择 | 加载并回显某条任务(同时回传标题/简介) |
|
||||
| `refreshHistory()` | — | 重新拉取历史列表(结果在 `history` 里) |
|
||||
| `getResults()` | — | 取所有智能体返回内容 `AgentResult[]` |
|
||||
| `getResult(id)` | — | 取某个智能体返回内容 `AgentResult \| null` |
|
||||
|
||||
**状态(ref,组件实例上自动解包,模板里直接读无需 `.value`)**
|
||||
|
||||
| 状态 | 类型 | 说明 |
|
||||
| -------------- | ---------------- | ----------------------------------- |
|
||||
| `running` | `boolean` | 是否正在并行运行(用于切「停止」按钮)|
|
||||
| `saving` | `boolean` | 是否正在保存 |
|
||||
| `hasResults` | `boolean` | 是否已有结果(「开始」/「重新」文案 & 保存可用)|
|
||||
| `canStart` | `boolean` | 是否满足开始条件(有 agents + 任务名或简介)|
|
||||
| `history` | `SavedTask[]` | 已保存任务列表(宿主可自建历史下拉)|
|
||||
| `errorMsg` | `string \| null` | 最近一次错误(面板内也会展示一条) |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
|
||||
// JS 里读 ref 状态要带 .value:
|
||||
function onClickStart() {
|
||||
if (panel.value?.canStart) panel.value.reanalyze();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- 模板里自动解包,直接读 -->
|
||||
<button :disabled="!panel?.canStart" @click="panel?.reanalyze()">
|
||||
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六·一、每个参数怎么传(逐个举例)
|
||||
|
||||
### `agents`(必填)——「智能体参数」怎么传
|
||||
@@ -171,11 +287,24 @@ const agents = [
|
||||
<ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" />
|
||||
```
|
||||
|
||||
### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切)
|
||||
### `model`——指定模型(宿主持有;不传走默认/后端回退)
|
||||
|
||||
```vue
|
||||
<ParallelAgentsPanel :model="'deepseek-chat'" :username="'demo'" :agents="agents" />
|
||||
<ParallelAgentsPanel :model="model" :username="'demo'" :agents="agents" />
|
||||
```
|
||||
> 模型下拉已移到宿主侧:宿主自己渲染下拉(可调后端 `/api/models`),把选中的模型名用 `:model` 传进来。
|
||||
|
||||
### `taskName` / `brief`——任务名 / 简介(宿主持有,支持 v-model)
|
||||
|
||||
```vue
|
||||
<ParallelAgentsPanel
|
||||
v-model:task-name="taskName"
|
||||
v-model:brief="brief"
|
||||
:username="'demo'"
|
||||
:agents="agents"
|
||||
/>
|
||||
```
|
||||
> 用 `v-model` 是为了**回显**:加载已保存任务时面板会把存档标题 / 简介回传给宿主。
|
||||
|
||||
### `dark`——深色(默认 true,自带成色)
|
||||
|
||||
@@ -208,6 +337,11 @@ const agents: AgentInput[] = [
|
||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
||||
];
|
||||
const taskId = ref("task-001");
|
||||
const taskName = ref("");
|
||||
const brief = ref("");
|
||||
const model = ref("deepseek-chat");
|
||||
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
|
||||
|
||||
function onSaved(t: SavedTask) {
|
||||
console.log("已保存", t.id);
|
||||
}
|
||||
@@ -217,13 +351,26 @@ function onCompleted(results: AgentResult[]) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="height: 86vh">
|
||||
<div style="display: flex; flex-direction: column; height: 86vh">
|
||||
<!-- 宿主工具条 -->
|
||||
<input v-model="taskName" placeholder="任务名称" />
|
||||
<textarea v-model="brief" placeholder="任务简介" />
|
||||
<button v-if="!panel?.running" :disabled="!panel?.canStart" @click="panel?.reanalyze()">
|
||||
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
|
||||
</button>
|
||||
<button v-else @click="panel?.cancel()">停止</button>
|
||||
<button :disabled="panel?.saving || !panel?.hasResults" @click="panel?.save()">保存</button>
|
||||
|
||||
<ParallelAgentsPanel
|
||||
ref="panel"
|
||||
style="flex: 1; min-height: 0"
|
||||
:base-url="'http://localhost:8001'"
|
||||
:username="'demo'"
|
||||
:agents="agents"
|
||||
:task-id="taskId"
|
||||
:model="'deepseek-chat'"
|
||||
v-model:task-name="taskName"
|
||||
v-model:brief="brief"
|
||||
:model="model"
|
||||
:dark="true"
|
||||
@saved="onSaved"
|
||||
@update:taskId="(id) => (taskId = id)"
|
||||
@@ -237,9 +384,12 @@ function onCompleted(results: AgentResult[]) {
|
||||
|
||||
| 事件 | 载荷 | 触发时机 |
|
||||
| ----------------- | ------------------ | --------------------------------------- |
|
||||
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
|
||||
| `saved` | `SavedTask` | 保存成功(新建或更新)后;**含自动保存** |
|
||||
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
|
||||
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
|
||||
| `update:taskName` | `string` | 加载已保存任务回显标题(配合 `v-model:task-name`)|
|
||||
| `update:brief` | `string` | 加载已保存任务回显简介(配合 `v-model:brief`)|
|
||||
| `agent-completed` | `AgentResult` | **每个**智能体一分析完就回传它单个的结果(各席位各触发一次)|
|
||||
| `completed` | `AgentResult[]` | 一轮并行分析**全部**结束时,回传所有智能体返回内容 |
|
||||
|
||||
```vue
|
||||
<ParallelAgentsPanel
|
||||
@@ -254,9 +404,22 @@ function onCompleted(results: AgentResult[]) {
|
||||
|
||||
## 七·一、如何获取模型返回的内容
|
||||
|
||||
每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一:
|
||||
每个智能体(模型)返回的正文是 `AgentResult.content`。组件会**自动把结果推给宿主**,无需轮询:
|
||||
|
||||
**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`:
|
||||
**A·1. 单个完成就推**:监听 `@agent-completed`,**哪个智能体先分析完就先回传哪个**(各触发一次):
|
||||
|
||||
```vue
|
||||
<ParallelAgentsPanel
|
||||
:username="'demo'"
|
||||
:agents="agents"
|
||||
@agent-completed="(r) => {
|
||||
// r: { agent_id, name, content, status } —— 单个智能体
|
||||
console.log('完成一个:', r.name, '→', r.content);
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
**A·2. 全部完成再推一次**:监听 `@completed`,一轮分析**全部**结束时回传 `AgentResult[]`:
|
||||
|
||||
```vue
|
||||
<ParallelAgentsPanel
|
||||
@@ -269,6 +432,8 @@ function onCompleted(results: AgentResult[]) {
|
||||
/>
|
||||
```
|
||||
|
||||
> 两个事件可同时用:`agent-completed` 做「来一个渲染一个」的增量更新,`completed` 做「全部到齐」的汇总。
|
||||
|
||||
**B. 方法(模板 ref)**:随时主动取(不必等事件):
|
||||
|
||||
```vue
|
||||
@@ -294,14 +459,25 @@ function readOne(id: string) {
|
||||
```
|
||||
|
||||
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
|
||||
`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。
|
||||
`status` 取值:`pending | running | done | clarify | error`。**`clarify` = 该智能体在等用户协助
|
||||
(待确认),不是已完成**——宿主判断「是否全部完成」时应排除 `clarify`(例如
|
||||
`results.every(r => r.status === "done")`),不要把待协助当成 done。
|
||||
`defineExpose` 还暴露了 `reanalyze()` / `start()` / `save()` / `cancel()` / `loadTask(id)` 等方法及
|
||||
`running` / `saving` / `hasResults` / `canStart` / `history` 等状态,完整清单见 [§六·二](#六二暴露的方法与状态模板-ref)。
|
||||
|
||||
## 七·二、如何保存 + 回显聊天记录
|
||||
|
||||
**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
|
||||
**保存**:宿主调用 `panel.save()` 即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
|
||||
传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端
|
||||
生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。
|
||||
|
||||
> **自动保存**:每轮分析(含单席位重跑)**结束后会自动保存一次**,宿主无需手动点保存也不丢记录;
|
||||
> 自动保存同样走 upsert,并照常触发 `saved` / `update:taskId` 事件(首轮会回传新生成的 id)。
|
||||
> `panel.save()` 仍保留,供宿主想随时手动存档时调用。
|
||||
|
||||
> **进入默认显示最新**:组件挂载时若**没传 `taskId`**,会自动加载并回显**该用户最新的一条**聊天记录
|
||||
> (历史按更新时间倒序,取第一条);传了 `taskId` 则优先回显指定那条。
|
||||
|
||||
**回显(两种方式)**:
|
||||
|
||||
```vue
|
||||
@@ -316,7 +492,20 @@ const taskId = ref<string>(""); // 留空=新任务
|
||||
taskId.value = "task-001"; // 改了之后面板自动回显 task-001
|
||||
```
|
||||
|
||||
> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。
|
||||
> 方式 2:**历史下拉已移到宿主侧**(面板不再内置)。宿主读 `panel.history`(`SavedTask[]`)自建下拉,
|
||||
> 选中某条时调 `panel.loadTask(id)` 回显;面板会通过 `update:taskName` / `update:brief` 把存档标题 / 简介
|
||||
> 回传给宿主,配合 `v-model` 同步宿主自己的输入框。需要刷新列表调 `panel.refreshHistory()`。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 宿主自建历史下拉 -->
|
||||
<select @change="(e) => panel?.loadTask((e.target as HTMLSelectElement).value)">
|
||||
<option v-for="t in panel?.history || []" :key="t.id" :value="t.id">
|
||||
{{ t.title || "未命名任务" }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
```
|
||||
|
||||
要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白
|
||||
(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。
|
||||
@@ -325,20 +514,27 @@ taskId.value = "task-001"; // 改了之后面板自动回显 task-
|
||||
|
||||
## 八、交互要点(与圆桌 Step2 一致)
|
||||
|
||||
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/
|
||||
`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、
|
||||
`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。
|
||||
- 宿主备好「任务名称 + 简介(+ 模型)」props → 调 `panel.reanalyze()` → 各智能体**并行**输出。
|
||||
运行期间禁用**写文件**(`write_file`/`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,
|
||||
防子智能体模仿总控调度);检索、查看、`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常
|
||||
显示**,每个智能体只做自己那份分析。
|
||||
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
|
||||
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。
|
||||
- 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。
|
||||
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
|
||||
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
|
||||
- 顶部席位状态条显示每个智能体的进行中 / 完成 / **待协助** / 出错;待协助数量会单独标「N 待协助」。
|
||||
- **用户协助(澄清)**:智能体要澄清时该席位变「待协助」(不是完成),气泡里弹协作卡片。多个智能体
|
||||
同时待协助时**可分别点协助、互不阻塞**(协助 A 不会禁用 B/C 的卡片);提交回答后该席位自动带上
|
||||
补充重跑。
|
||||
- **任务名 / 简介 / 模型 / 按钮 / 历史下拉全部在宿主侧**:模型空 = `model` prop / 配置 / 后端回退;
|
||||
模型作用于**下一次**`reanalyze()`。
|
||||
- `panel.save()` 存档;`panel.loadTask(id)` 回显;`panel.reanalyze()` 清空重跑;`panel.cancel()` 停止。
|
||||
- **每轮分析结束自动保存一次**;挂载时没传 `taskId` 会**默认回显最新一条**记录。
|
||||
- 结果自动推给宿主:`@agent-completed`(来一个推一个)+ `@completed`(全部到齐再推一次)。
|
||||
|
||||
---
|
||||
|
||||
## 九、注意事项
|
||||
|
||||
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。
|
||||
- **不会污染宿主全局样式**:**不依赖 Tailwind**,布局类已内置为作用域工具类子集,所有规则都在
|
||||
`.parallel-agents` 下,绝不外泄到宿主页面。
|
||||
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
|
||||
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
|
||||
业务请求用 Bearer 头已绕过 CSRF。
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| 框架 | Vue 3(`<script setup>` + Composition API) |
|
||||
| 构建 | Vite 6(dev app + `build:lib` 组件库双产物) |
|
||||
| 语言 | TypeScript(`strict` + `noUnusedLocals/Parameters`) |
|
||||
| 样式 | TailwindCSS v4(**仅 utilities,不引 preflight**)+ 作用域 `.pa-*` token |
|
||||
| 样式 | **零 CSS 框架**:作用域 `.pa-*` token + 内置「最小工具类子集」(不依赖 Tailwind) |
|
||||
| Markdown | `markdown-it`(打进库,宿主无需安装) |
|
||||
| peer dep | `vue ^3.4`(宿主提供,库不打包 vue) |
|
||||
|
||||
@@ -31,31 +31,35 @@
|
||||
|
||||
## 3. 目录结构与各文件职责
|
||||
|
||||
实现全部收在**一个自包含文件夹** `src/parallel-agents/`(内部相对路径引用、不依赖构建别名,
|
||||
整个文件夹可直接拷给宿主用)。`src/` 顶层只剩 demo 与库入口。
|
||||
|
||||
```
|
||||
src/
|
||||
├─ index.ts 库入口:导出 ParallelAgentsPanel + install 插件 + 类型;import 样式
|
||||
├─ index.ts 库入口:从 ./parallel-agents 再导出 + install 插件
|
||||
├─ main.ts / App.vue dev demo(仅本地调试用,不进库)
|
||||
├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH
|
||||
├─ types.ts 共享类型:Dialogue / SeatView / DialogueStep / ClarificationPayload
|
||||
│ / AgentInput / AgentResult / SavedTask / StreamEvent(事件协议)
|
||||
├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表
|
||||
├─ composables/
|
||||
│ └─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats;
|
||||
│ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑)
|
||||
├─ lib/
|
||||
│ ├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转)
|
||||
│ └─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点)
|
||||
├─ styles/
|
||||
│ └─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式(无 preflight)
|
||||
└─ components/
|
||||
├─ ParallelAgentsPanel.vue 主组件:输入区/操作区/模型切换/历史/席位条/会话区 + 全部状态编排
|
||||
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/error 角标,点击切换单显)
|
||||
└─ parallel-agents/ ← 自包含组件文件夹(拷走即用)
|
||||
├─ index.ts 文件夹入口:import 样式 + 导出组件/类型
|
||||
├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH
|
||||
├─ types.ts 共享类型:Dialogue / SeatView / SeatStatus / DialogueStep /
|
||||
│ ClarificationPayload / AgentInput / AgentResult / SavedTask / StreamEvent
|
||||
├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表
|
||||
├─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats/busy;
|
||||
│ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑,可并发协助)
|
||||
├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转)
|
||||
├─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点)
|
||||
├─ 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)
|
||||
├─ ClarificationCard.vue 协作 / 澄清卡片(1:1 对齐圆桌第一步 IntentClarificationCard)
|
||||
├─ MarkdownContent.vue markdown-it 渲染
|
||||
└─ Avatar.vue 机器人 / 真人 SVG 头像
|
||||
├─ Avatar.vue 机器人 / 真人 SVG 头像
|
||||
└─ README.md 文件夹速用说明
|
||||
```
|
||||
|
||||
---
|
||||
@@ -98,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` → 卡片 |
|
||||
| `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` | — | 全部结束 |
|
||||
@@ -106,9 +110,10 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
|
||||
**核心数据模型**(`types.ts`):
|
||||
- `Dialogue { id, agentId, sender, avatarType, content, time, confidence, streaming?, steps?, clarification? }`
|
||||
- `DialogueStep { id, key, label, ts, args? }`(`key` 形如 `tool_calling:web_search`)
|
||||
- `SeatView { agentId, name, avatarType, status: pending|running|done|error, bubbleId }`
|
||||
- `SeatView { agentId, name, avatarType, status: SeatStatus, bubbleId }`
|
||||
- `SeatStatus = pending | running | done | clarify | error`(**`clarify` = 待用户协助,非完成**)
|
||||
- `ClarificationPayload { question, clarificationType, context, options, allowCustom, allowMultiple }`
|
||||
- `AgentResult { agent_id, name, content, status }`(宿主读取用)
|
||||
- `AgentResult { agent_id, name, content, status }`(宿主读取用;`status` 同 `SeatStatus`)
|
||||
|
||||
> 改协议时:`types.ts` 的 `StreamEvent` 与后端 `parallel_agents.py` 的事件 dict **必须同步**。
|
||||
|
||||
@@ -119,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)`
|
||||
|
||||
---
|
||||
|
||||
@@ -159,21 +177,32 @@ ParallelAgentsPanel
|
||||
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
|
||||
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行
|
||||
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。
|
||||
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常。
|
||||
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常(**用户协助开启**)。
|
||||
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。
|
||||
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
|
||||
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。
|
||||
|
||||
**前端澄清(用户协助)交互要点**(`useParallelRun` / `ClarificationCard` / `ParallelAgentsPanel`):
|
||||
- 协作卡片**不再因 `submitting` 禁用**:整批仍在跑时也能立刻回答某个已澄清的席位。
|
||||
- `rerunAgent` 放开了「整批 running 时禁止重跑」的限制(澄清是该席位合并流里的终态,不会再抢气泡),
|
||||
只拦同一席位的并发重跑;收尾循环(`start` 的 finally)**跳过 busy 席位**,交给 `rerunAgent` 自己结算。
|
||||
- 答澄清后**不立刻清空**正文/步骤:先移除卡片、置流式并标记 `rerunPending`,等重跑产出**第一条新事件**
|
||||
时(`maybeStartRerun`)再清空替换,避免单选视图整列空白闪烁。
|
||||
- 「单席位结算回传」`onAgentSettled`:**`clarify` 不回传**(待用户协助≠模型完成),等重跑出 `done/error` 再回传。
|
||||
|
||||
---
|
||||
|
||||
## 9. 样式系统(嵌入安全)
|
||||
|
||||
`styles/parallel-agents.css`:
|
||||
- **不引 Tailwind preflight**(`@import "tailwindcss/theme.css"` + `utilities.css`),避免重置宿主全局样式。
|
||||
`parallel-agents.css`(**零依赖、不再用 Tailwind**):
|
||||
- 颜色全用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》,与圆桌 Step2 深色一致),全部包在
|
||||
根类 `.parallel-agents` 下,**不污染宿主**。
|
||||
- 语义类 `.pa-card / .pa-surface / .pa-border / .pa-text(-muted/-primary) / .pa-input / .pa-btn-* /
|
||||
.pa-badge / .pa-md(Markdown)` —— 组件**上色用这些类**,布局用 Tailwind 工具类。
|
||||
.pa-badge / .pa-dot-*(席位角标,含 .pa-dot-clarify) / .pa-md(Markdown)` —— 组件**上色用这些类**。
|
||||
- 布局用文件**末尾的「最小工具类子集」**(`flex` / `gap-*` / `rounded-*` / `text-[11px]` /
|
||||
`lg:grid-cols-2` 等,模板用到的逐条手写成纯 CSS)。它包在 `@layer pa-utilities` 里,所以无层级的
|
||||
`.pa-*` 语义类在同属性冲突时**仍压过**工具类(复刻原先 Tailwind-utilities-在-layer 的优先级)。
|
||||
**维护**:模板新增布局类时,在该子集补一条对应规则即可(或写组件 scoped `<style>`)。
|
||||
- 面板**自带深色成色**,不随宿主明暗变化;根元素 `h-full w-full flex flex-col min-h-0` 自适应容器。
|
||||
|
||||
---
|
||||
@@ -181,7 +210,7 @@ ParallelAgentsPanel
|
||||
## 10. 步骤条 / 协作卡片(与圆桌的对应)
|
||||
|
||||
- **步骤条** `MessageStepsCard.vue` ← 圆桌 `MessageStepsCard.tsx` + `ChainOfThought`:图标列(带竖向连接线)
|
||||
+ 中文文案 + 时间 + 参数子节点。文案/图标映射在 `lib/step-display.ts`(`getStepDisplay`),参数子节点
|
||||
+ 中文文案 + 时间 + 参数子节点。文案/图标映射在 `step-display.ts`(`getStepDisplay`),参数子节点
|
||||
在 `getStepArgs`(chip / code / text / kv)。加新工具图标:在 `step-display.ts` 的 switch + `StepIcon.vue`
|
||||
各加一条。
|
||||
- **协作卡片** `ClarificationCard.vue` ← 圆桌第一步 `IntentClarificationCard.tsx`:标题/类型标签/背景/选项
|
||||
@@ -208,10 +237,12 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
|
||||
| 需求 | 改哪里 |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| 新增一个 SSE 事件类型 | `types.ts` 的 `StreamEvent` + `useParallelRun.handleEvent` + 后端 `parallel_agents.py` |
|
||||
| 步骤条支持新工具的图标/文案 | `lib/step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` |
|
||||
| 改深色配色 / 加浅色主题 | `styles/parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) |
|
||||
| 步骤条支持新工具的图标/文案 | `step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` |
|
||||
| 改深色配色 / 加浅色主题 | `parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) |
|
||||
| 改布局类 / 加新工具类 | `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` |
|
||||
|
||||
@@ -221,6 +252,8 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
|
||||
|
||||
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
|
||||
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
|
||||
- **用户协助(澄清)已启用**:`ask_clarification` 保留;席位信息不足时可弹协作卡片。卡片任何时候都可点
|
||||
(含整批仍在跑时),用户答完即重跑该席位;待协助(clarify)不算完成、不回传给宿主。
|
||||
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。
|
||||
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
|
||||
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
|
||||
@@ -29,10 +29,8 @@
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue": "^3.5.0",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
|
||||
/**
|
||||
* Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。
|
||||
*
|
||||
* 同时作为「新契约」的参考实现:任务名 / 简介 / 模型由宿主持有并通过 props 传入,
|
||||
* 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 ref 调用面板 expose 出的方法。
|
||||
*/
|
||||
import { reactive, ref } from "vue";
|
||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
||||
import type { AgentInput, AgentResult } from "./types";
|
||||
import { ParallelAgentsPanel } from "./parallel-agents";
|
||||
import type { AgentInput, AgentReply } from "./parallel-agents";
|
||||
|
||||
import { DEFAULT_BASE_URL } from "./config";
|
||||
import { DEFAULT_BASE_URL } from "./parallel-agents/config";
|
||||
|
||||
const cfg = reactive({
|
||||
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
|
||||
@@ -16,13 +21,24 @@ const cfg = reactive({
|
||||
const open = ref(false);
|
||||
const agents = ref<AgentInput[]>([]);
|
||||
|
||||
// 宿主持有:任务名 / 简介 / 模型(面板不再渲染这些输入)。
|
||||
const taskName = ref(localStorage.getItem("pa.taskName") || "");
|
||||
const brief = ref(localStorage.getItem("pa.brief") || "");
|
||||
const model = ref(localStorage.getItem("pa.model") || "");
|
||||
|
||||
// 模板 ref:拿到面板暴露的方法 / 状态。
|
||||
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
|
||||
|
||||
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
|
||||
function onTaskId(id: string) {
|
||||
cfg.taskId = id;
|
||||
localStorage.setItem("pa.taskId", id);
|
||||
}
|
||||
function onCompleted(results: AgentResult[]) {
|
||||
console.log("[demo] completed → 各智能体返回内容:", results);
|
||||
function onAgentCompleted(result: AgentReply) {
|
||||
console.log("[demo] agent-completed → 某智能体分析完:", result.name, result.content);
|
||||
}
|
||||
function onCompleted(results: AgentReply[]) {
|
||||
console.log("[demo] completed → 全部分析完,各智能体返回内容:", results);
|
||||
}
|
||||
|
||||
function parseAgents(): AgentInput[] {
|
||||
@@ -68,21 +84,74 @@ function launch() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 模拟宿主弹窗:面板自适应容器 -->
|
||||
<!-- 模拟宿主弹窗:宿主自己渲染「任务名 / 简介 / 模型 + 按钮」,面板只展示会话 -->
|
||||
<div
|
||||
v-if="open"
|
||||
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
||||
@click.self="open = false"
|
||||
>
|
||||
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
|
||||
<ParallelAgentsPanel
|
||||
:base-url="cfg.baseUrl"
|
||||
:username="cfg.username || undefined"
|
||||
:task-id="cfg.taskId || undefined"
|
||||
:agents="agents"
|
||||
@update:taskId="onTaskId"
|
||||
@completed="onCompleted"
|
||||
/>
|
||||
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5); display: flex; flex-direction: column; background: #0d1526">
|
||||
<!-- 宿主工具条:这部分原来在面板内,现在由宿主自己掌控 -->
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-bottom: 1px solid #1e2d4d">
|
||||
<input
|
||||
v-model="taskName"
|
||||
placeholder="任务名称"
|
||||
style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
|
||||
/>
|
||||
<textarea
|
||||
v-model="brief"
|
||||
rows="2"
|
||||
placeholder="任务简介:把背景、目标、约束写清楚"
|
||||
style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px; resize: vertical"
|
||||
/>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
|
||||
<!-- expose 出的 ref 在组件实例上会自动解包,直接读,无需 .value -->
|
||||
<button
|
||||
v-if="!panel?.running"
|
||||
:disabled="!panel?.canStart"
|
||||
@click="panel?.reanalyze()"
|
||||
style="padding: 6px 14px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer"
|
||||
>
|
||||
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="panel?.cancel()"
|
||||
style="padding: 6px 14px; background: #2d4473; color: #e5edf7; border: 0; border-radius: 6px; cursor: pointer"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
:disabled="panel?.saving || !panel?.hasResults"
|
||||
@click="panel?.save()"
|
||||
style="padding: 6px 14px; background: #2d4473; color: #e5edf7; border: 0; border-radius: 6px; cursor: pointer"
|
||||
>
|
||||
{{ panel?.saving ? "保存中…" : cfg.taskId ? "更新保存" : "保存" }}
|
||||
</button>
|
||||
<input
|
||||
v-model="model"
|
||||
placeholder="模型(留空=默认)"
|
||||
style="margin-left: auto; width: 200px; padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 面板:自适应容器,纯展示会话 -->
|
||||
<div style="flex: 1; min-height: 0">
|
||||
<ParallelAgentsPanel
|
||||
ref="panel"
|
||||
:base-url="cfg.baseUrl"
|
||||
:username="cfg.username || undefined"
|
||||
:task-id="cfg.taskId || undefined"
|
||||
v-model:task-name="taskName"
|
||||
v-model:brief="brief"
|
||||
:model="model"
|
||||
:agents="agents"
|
||||
@update:taskId="onTaskId"
|
||||
@agent-completed="onAgentCompleted"
|
||||
@completed="onCompleted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 并行多智能体面板(主组件,可嵌入)。
|
||||
*
|
||||
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
|
||||
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
|
||||
* 聊天记录 + 回显 + 重新分析。
|
||||
*
|
||||
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
|
||||
import { useParallelRun } from "@/composables/useParallelRun";
|
||||
import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api";
|
||||
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
|
||||
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
|
||||
import MessageBubble from "./MessageBubble.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
|
||||
baseUrl?: string;
|
||||
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
|
||||
token?: string;
|
||||
getToken?: () => string | Promise<string>;
|
||||
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
|
||||
username?: string;
|
||||
/** 仅当后端开启口令门时需要。 */
|
||||
loginPassword?: string;
|
||||
agents: AgentInput[];
|
||||
taskId?: string;
|
||||
model?: string;
|
||||
dark?: boolean;
|
||||
}>(),
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "saved", task: SavedTask): void;
|
||||
(e: "update:taskId", id: string): void;
|
||||
/** 一轮并行分析结束时回传各智能体的返回内容。 */
|
||||
(e: "completed", results: AgentResult[]): void;
|
||||
}>();
|
||||
|
||||
const cfg = computed<ApiConfig>(() => ({
|
||||
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
|
||||
token: props.token,
|
||||
getToken: props.getToken,
|
||||
username: props.username,
|
||||
loginPassword: props.loginPassword,
|
||||
}));
|
||||
|
||||
const run = useParallelRun();
|
||||
const { dialogues, seats, running } = run;
|
||||
|
||||
const form = reactive({ taskName: "", brief: "" });
|
||||
const currentTaskId = ref<string | null>(props.taskId ?? null);
|
||||
const selected = ref<string[]>([]);
|
||||
const saving = ref(false);
|
||||
const history = ref<SavedTask[]>([]);
|
||||
const showHistory = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
|
||||
const models = ref<ModelItem[]>([]);
|
||||
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
|
||||
|
||||
const dialogueById = computed(() => {
|
||||
const m = new Map<number, Dialogue>();
|
||||
for (const d of dialogues.value) m.set(d.id, d);
|
||||
return m;
|
||||
});
|
||||
|
||||
const hasResults = computed(() => dialogues.value.length > 0);
|
||||
const canStart = computed(
|
||||
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
|
||||
);
|
||||
|
||||
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
|
||||
watch(
|
||||
seats,
|
||||
(s) => {
|
||||
if (!s.length) {
|
||||
selected.value = [];
|
||||
return;
|
||||
}
|
||||
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
|
||||
const cur = selected.value[0];
|
||||
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
|
||||
selected.value = [keep];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
|
||||
watch(
|
||||
() => props.taskId,
|
||||
(id) => {
|
||||
currentTaskId.value = id ?? null;
|
||||
if (id) void loadTask(id);
|
||||
},
|
||||
);
|
||||
|
||||
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
|
||||
function toggleSeat(agentId: string) {
|
||||
selected.value = [agentId];
|
||||
}
|
||||
|
||||
const visibleSeats = computed(() => {
|
||||
const id = selected.value[0] ?? seats.value[0]?.agentId;
|
||||
const seat = seats.value.find((s) => s.agentId === id);
|
||||
return seat ? [seat] : [];
|
||||
});
|
||||
|
||||
// ── 给宿主读取「每个智能体模型返回的内容」的简单方法 ──────────────────────────
|
||||
function _statusOf(agentId: string, streaming?: boolean): SeatView["status"] {
|
||||
return seats.value.find((s) => s.agentId === agentId)?.status ?? (streaming ? "running" : "done");
|
||||
}
|
||||
/** 取所有智能体的返回内容(按席位顺序)。宿主:`panelRef.value.getResults()`。 */
|
||||
function getResults(): AgentResult[] {
|
||||
return dialogues.value.map((d) => ({
|
||||
agent_id: d.agentId,
|
||||
name: d.sender,
|
||||
content: d.content,
|
||||
status: _statusOf(d.agentId, d.streaming),
|
||||
}));
|
||||
}
|
||||
/** 取某个智能体的返回内容;不存在返回 null。 */
|
||||
function getResult(agentId: string): AgentResult | null {
|
||||
return getResults().find((r) => r.agent_id === agentId) ?? null;
|
||||
}
|
||||
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />` → panelRef.value.getResults()
|
||||
defineExpose({ getResults, getResult, start: () => onStart(), cancel: () => run.cancel() });
|
||||
|
||||
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
|
||||
async function onClarify(agentId: string, answer: string) {
|
||||
errorMsg.value = null;
|
||||
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim();
|
||||
try {
|
||||
await run.rerunAgent(cfg.value, {
|
||||
taskName: form.taskName,
|
||||
brief,
|
||||
agentId,
|
||||
model: selectedModel.value || undefined,
|
||||
});
|
||||
emit("completed", getResults());
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onStart() {
|
||||
if (!canStart.value) return;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
await run.start(cfg.value, {
|
||||
taskName: form.taskName,
|
||||
brief: form.brief,
|
||||
agents: props.agents,
|
||||
model: selectedModel.value || undefined,
|
||||
});
|
||||
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
|
||||
emit("completed", getResults());
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (saving.value || !hasResults.value) return;
|
||||
saving.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const title = form.taskName.trim() || "未命名任务";
|
||||
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
|
||||
const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value };
|
||||
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
|
||||
let saved: SavedTask | null;
|
||||
const id = currentTaskId.value;
|
||||
if (id) {
|
||||
saved = await updateTask(cfg.value, id, payload);
|
||||
if (!saved) saved = await createTask(cfg.value, { id, ...payload });
|
||||
} else {
|
||||
saved = await createTask(cfg.value, payload);
|
||||
}
|
||||
if (saved) {
|
||||
currentTaskId.value = saved.id;
|
||||
emit("update:taskId", saved.id);
|
||||
emit("saved", saved);
|
||||
await refreshHistory();
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHistory() {
|
||||
try {
|
||||
history.value = await listTasks(cfg.value);
|
||||
} catch {
|
||||
/* 历史拉取失败不阻断主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTask(id: string) {
|
||||
showHistory.value = false;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const task = await getTask(cfg.value, id);
|
||||
if (!task) return;
|
||||
currentTaskId.value = task.id;
|
||||
form.taskName = task.title ?? "";
|
||||
form.brief = task.brief ?? "";
|
||||
selected.value = [];
|
||||
run.hydrate(task.messages ?? []);
|
||||
emit("update:taskId", task.id);
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels() {
|
||||
const list = await listModels(cfg.value);
|
||||
models.value = list;
|
||||
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
|
||||
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
|
||||
selectedModel.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshHistory();
|
||||
void refreshModels();
|
||||
if (props.taskId) void loadTask(props.taskId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
|
||||
:class="dark ? 'parallel-agents--dark' : ''"
|
||||
>
|
||||
<!-- 顶部:任务名 + 操作 -->
|
||||
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="form.taskName"
|
||||
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
|
||||
placeholder="任务名称"
|
||||
/>
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
|
||||
@click="showHistory = !showHistory"
|
||||
>
|
||||
历史 ▾
|
||||
</button>
|
||||
<div
|
||||
v-if="showHistory"
|
||||
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
|
||||
>
|
||||
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
|
||||
暂无保存的任务
|
||||
</div>
|
||||
<button
|
||||
v-for="t in history"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
|
||||
style="border: 0"
|
||||
@click="loadTask(t.id)"
|
||||
>
|
||||
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
|
||||
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.brief"
|
||||
rows="2"
|
||||
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
|
||||
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="!running"
|
||||
type="button"
|
||||
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
|
||||
:disabled="!canStart"
|
||||
@click="onStart"
|
||||
>
|
||||
{{ hasResults ? "重新分析" : "开始分析" }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
|
||||
@click="run.cancel()"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
|
||||
:disabled="saving || !hasResults"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
|
||||
</button>
|
||||
|
||||
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
|
||||
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
|
||||
模型
|
||||
<select
|
||||
v-model="selectedModel"
|
||||
:disabled="running"
|
||||
class="pa-input px-2 py-1 text-xs"
|
||||
title="选择本次并行分析使用的模型"
|
||||
>
|
||||
<option value="">默认</option>
|
||||
<option v-for="m in models" :key="m.name" :value="m.name">
|
||||
{{ m.display_name || m.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
|
||||
⚠️ 未传入任何智能体(agents 为空)
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 席位状态条 -->
|
||||
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
|
||||
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
|
||||
</div>
|
||||
|
||||
<!-- 会话区:并行双列 -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
v-if="hasResults"
|
||||
class="grid gap-4"
|
||||
: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"
|
||||
>
|
||||
<MessageBubble
|
||||
v-if="dialogueById.get(seat.bubbleId)"
|
||||
:msg="dialogueById.get(seat.bubbleId)!"
|
||||
:submitting="running"
|
||||
@clarify="onClarify"
|
||||
/>
|
||||
<div v-else class="py-6 text-center text-xs pa-text-muted">
|
||||
{{ seat.name }} · 等待输出…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-center"
|
||||
>
|
||||
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
|
||||
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,13 +3,24 @@
|
||||
* import { ParallelAgentsPanel } from "parallel-agents-vue";
|
||||
* import "parallel-agents-vue/style.css";
|
||||
* 或全局注册:app.use(ParallelAgentsVue)。
|
||||
*
|
||||
* 实现全部收在自包含的 `./parallel-agents/` 文件夹里(含样式,零 Tailwind 依赖)。需要直接拷
|
||||
* 源码的用户,整个 parallel-agents/ 文件夹拷走即用,见该目录下 README.md。
|
||||
*/
|
||||
import "./styles/parallel-agents.css";
|
||||
import type { App } from "vue";
|
||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
||||
import { ParallelAgentsPanel } from "./parallel-agents";
|
||||
|
||||
export { ParallelAgentsPanel };
|
||||
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
|
||||
export type {
|
||||
AgentInput,
|
||||
AgentReply,
|
||||
AgentResult,
|
||||
Dialogue,
|
||||
SavedTask,
|
||||
SeatStatus,
|
||||
SeatView,
|
||||
StreamEvent,
|
||||
} from "./parallel-agents";
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles/parallel-agents.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { avatarColor, isHuman } from "@/lib/avatar-constants";
|
||||
import { avatarColor, isHuman } from "./avatar-constants";
|
||||
|
||||
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
|
||||
const color = computed(() => avatarColor(props.type));
|
||||
+9
-8
@@ -5,7 +5,7 @@
|
||||
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
|
||||
*/
|
||||
import { computed, reactive } from "vue";
|
||||
import type { ClarificationPayload, ClarOption } from "@/types";
|
||||
import type { ClarificationPayload, ClarOption } from "./types";
|
||||
|
||||
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "submit", answer: string): void }>();
|
||||
@@ -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>
|
||||
File renamed without changes.
+1
-1
@@ -2,7 +2,7 @@
|
||||
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
|
||||
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
|
||||
import { computed } from "vue";
|
||||
import type { Dialogue } from "@/types";
|
||||
import type { Dialogue } from "./types";
|
||||
import Avatar from "./Avatar.vue";
|
||||
import MessageStepsCard from "./MessageStepsCard.vue";
|
||||
import MarkdownContent from "./MarkdownContent.vue";
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import type { DialogueStep } from "@/types";
|
||||
import { getStepArgs, getStepDisplay } from "@/lib/step-display";
|
||||
import type { DialogueStep } from "./types";
|
||||
import { getStepArgs, getStepDisplay } from "./step-display";
|
||||
import StepIcon from "./StepIcon.vue";
|
||||
|
||||
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 并行多智能体面板(主组件,可嵌入)—— 纯展示 + 暴露方法版本。
|
||||
*
|
||||
* 「任务名称 / 简介 / 模型」全部由宿主通过 props 传入;本组件不再渲染顶部表单与按钮,
|
||||
* 只展示「席位状态条 + 并行会话」。宿主自行渲染「重新分析 / 保存」等按钮,并通过模板 ref
|
||||
* 调用本组件暴露的 `reanalyze()` / `save()` / `cancel()` 等方法。
|
||||
*
|
||||
* 并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表照搬圆桌 Step2 并行多席位的
|
||||
* 深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存聊天记录 + 回显 + 重新分析。
|
||||
*
|
||||
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import type { AgentInput, AgentReply, AgentResult, Dialogue, SavedTask, SeatView } from "./types";
|
||||
import { useParallelRun } from "./useParallelRun";
|
||||
import {
|
||||
createTask,
|
||||
ensureTask,
|
||||
listTasks,
|
||||
updateTask,
|
||||
type ApiConfig,
|
||||
} from "./api";
|
||||
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<{
|
||||
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
|
||||
baseUrl?: string;
|
||||
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
|
||||
token?: string;
|
||||
getToken?: () => string | Promise<string>;
|
||||
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
|
||||
username?: string;
|
||||
/** 仅当后端开启口令门时需要。 */
|
||||
loginPassword?: string;
|
||||
agents: AgentInput[];
|
||||
taskId?: string;
|
||||
/** 任务名称:由宿主传入(可配合 update:taskName 做 v-model)。 */
|
||||
taskName?: string;
|
||||
/** 任务简介:由宿主传入(可配合 update:brief 做 v-model)。 */
|
||||
brief?: string;
|
||||
/** 本次并行分析使用的模型;空 = 后端回退默认。 */
|
||||
model?: string;
|
||||
dark?: boolean;
|
||||
}>(),
|
||||
{ dark: true, taskName: "", brief: "", model: "" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "saved", task: SavedTask): void;
|
||||
(e: "update:taskId", id: string): void;
|
||||
/** 回显已保存任务时回传标题,宿主可据此同步自己的输入框。 */
|
||||
(e: "update:taskName", name: string): void;
|
||||
/** 回显已保存任务时回传简介。 */
|
||||
(e: "update:brief", brief: string): void;
|
||||
/** 单个智能体**真正完成(done)**时回传它的结果对象 `{ agent_id, name, content }`(待协助/出错不发)。 */
|
||||
(e: "agent-completed", result: AgentReply): void;
|
||||
/** **所有**智能体都完成(done/error、无待协助)后,回传各智能体的返回结果数组。 */
|
||||
(e: "completed", results: AgentReply[]): void;
|
||||
}>();
|
||||
|
||||
const cfg = computed<ApiConfig>(() => ({
|
||||
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
|
||||
token: props.token,
|
||||
getToken: props.getToken,
|
||||
username: props.username,
|
||||
loginPassword: props.loginPassword,
|
||||
}));
|
||||
|
||||
// 单个智能体完成即回传它的结果(一分析完就给宿主)。
|
||||
const run = useParallelRun({
|
||||
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;
|
||||
|
||||
// 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。
|
||||
const taskName = computed(() => props.taskName ?? "");
|
||||
const brief = computed(() => props.brief ?? "");
|
||||
const model = computed(() => (props.model || "").trim() || undefined);
|
||||
|
||||
const currentTaskId = ref<string | null>(props.taskId ?? null);
|
||||
const selected = ref<string[]>([]);
|
||||
const saving = ref(false);
|
||||
const history = ref<SavedTask[]>([]);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
const dialogueById = computed(() => {
|
||||
const m = new Map<number, Dialogue>();
|
||||
for (const d of dialogues.value) m.set(d.id, d);
|
||||
return m;
|
||||
});
|
||||
|
||||
const hasResults = computed(() => dialogues.value.length > 0);
|
||||
const canStart = computed(
|
||||
() => !running.value && props.agents.length > 0 && !!(taskName.value.trim() || brief.value.trim()),
|
||||
);
|
||||
|
||||
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
|
||||
watch(
|
||||
seats,
|
||||
(s) => {
|
||||
if (!s.length) {
|
||||
selected.value = [];
|
||||
return;
|
||||
}
|
||||
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
|
||||
const cur = selected.value[0];
|
||||
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
|
||||
selected.value = [keep];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
|
||||
watch(
|
||||
() => props.taskId,
|
||||
(id) => {
|
||||
currentTaskId.value = id ?? null;
|
||||
if (id) void loadTask(id);
|
||||
},
|
||||
);
|
||||
|
||||
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
|
||||
function toggleSeat(agentId: string) {
|
||||
selected.value = [agentId];
|
||||
}
|
||||
|
||||
const visibleSeats = computed(() => {
|
||||
const id = selected.value[0] ?? seats.value[0]?.agentId;
|
||||
const seat = seats.value.find((s) => s.agentId === id);
|
||||
return seat ? [seat] : [];
|
||||
});
|
||||
|
||||
// ── 给宿主读取「每个智能体模型返回的内容」的简单方法 ──────────────────────────
|
||||
function _statusOf(agentId: string, streaming?: boolean): SeatView["status"] {
|
||||
return seats.value.find((s) => s.agentId === agentId)?.status ?? (streaming ? "running" : "done");
|
||||
}
|
||||
/** 取所有智能体的返回内容(按席位顺序)。宿主:`panelRef.value.getResults()`。 */
|
||||
function getResults(): AgentResult[] {
|
||||
return dialogues.value.map((d) => ({
|
||||
agent_id: d.agentId,
|
||||
name: d.sender,
|
||||
content: d.content,
|
||||
status: _statusOf(d.agentId, d.streaming),
|
||||
}));
|
||||
}
|
||||
/** 取某个智能体的返回内容;不存在返回 null。 */
|
||||
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() // 开始 / 重新分析
|
||||
// panelRef.value.save() // 保存 / 更新保存
|
||||
// panelRef.value.cancel() // 停止
|
||||
// panelRef.value.loadTask(id) // 回显某条历史
|
||||
// panelRef.value.running / saving / hasResults / canStart // 按钮态(ref)
|
||||
defineExpose({
|
||||
getResults,
|
||||
getResult,
|
||||
start: () => onStart(),
|
||||
reanalyze: () => onStart(),
|
||||
save: () => onSave(),
|
||||
cancel: () => run.cancel(),
|
||||
loadTask: (id: string) => loadTask(id),
|
||||
refreshHistory: () => refreshHistory(),
|
||||
history,
|
||||
running,
|
||||
saving,
|
||||
hasResults,
|
||||
canStart,
|
||||
errorMsg,
|
||||
});
|
||||
|
||||
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
|
||||
async function onClarify(agentId: string, answer: string) {
|
||||
errorMsg.value = null;
|
||||
const merged = `${brief.value}\n\n【用户补充】${answer}`.trim();
|
||||
try {
|
||||
await run.rerunAgent(cfg.value, {
|
||||
taskName: taskName.value,
|
||||
brief: merged,
|
||||
agentId,
|
||||
model: model.value,
|
||||
userNote: answer, // 在该席位历史里展示「我的补充」,协助前的内容不丢
|
||||
});
|
||||
// 批量 completed 由 onAgentSettled→maybeEmitCompleted 在「全部完成」时统一发;这里不再直接发。
|
||||
void autoSave(); // 重跑完也自动存一次
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onStart() {
|
||||
if (!canStart.value) return;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
await run.start(cfg.value, {
|
||||
taskName: taskName.value,
|
||||
brief: brief.value,
|
||||
agents: props.agents,
|
||||
model: model.value,
|
||||
});
|
||||
// 批量 completed 改由 onAgentSettled→maybeEmitCompleted 在「全部智能体完成」时统一发:
|
||||
// 若有席位在等用户协助(clarify),本轮不发批量,待其答完重跑完成后再发。
|
||||
void autoSave(); // 分析完自动保存一次
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一轮分析结束后自动保存一次(有结果才存;失败静默,不打断主流程)。 */
|
||||
async function autoSave() {
|
||||
if (!hasResults.value || saving.value) return;
|
||||
try {
|
||||
await onSave();
|
||||
} catch {
|
||||
/* 自动保存失败不打扰用户,错误已在 onSave 内记录 */
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (saving.value || !hasResults.value) return;
|
||||
saving.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const title = taskName.value.trim() || "未命名任务";
|
||||
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
|
||||
const payload = { title, brief: brief.value, agents: agentsSnapshot, messages: dialogues.value };
|
||||
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
|
||||
let saved: SavedTask | null;
|
||||
const id = currentTaskId.value;
|
||||
if (id) {
|
||||
saved = await updateTask(cfg.value, id, payload);
|
||||
if (!saved) saved = await createTask(cfg.value, { id, ...payload });
|
||||
} else {
|
||||
saved = await createTask(cfg.value, payload);
|
||||
}
|
||||
if (saved) {
|
||||
currentTaskId.value = saved.id;
|
||||
emit("update:taskId", saved.id);
|
||||
emit("saved", saved);
|
||||
await refreshHistory();
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHistory() {
|
||||
try {
|
||||
const list = await listTasks(cfg.value);
|
||||
// 按更新时间倒序,最新的在最前(供「进入默认显示最新一条」用)。
|
||||
history.value = [...list].sort((a, b) =>
|
||||
(b.updated_at || b.created_at || "").localeCompare(a.updated_at || a.created_at || ""),
|
||||
);
|
||||
} catch {
|
||||
/* 历史拉取失败不阻断主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTask(id: string) {
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
// get-or-create:新 id 直接初始化空记录(不 404);已存在则回显其聊天记录。
|
||||
const task = await ensureTask(cfg.value, {
|
||||
id,
|
||||
agents: props.agents.map((a) => ({ ...a })),
|
||||
});
|
||||
currentTaskId.value = task.id;
|
||||
selected.value = [];
|
||||
if (task.messages && task.messages.length) {
|
||||
// 标题 / 简介由宿主持有:回传让宿主同步自己的输入框(v-model)。
|
||||
emit("update:taskName", task.title ?? "");
|
||||
emit("update:brief", task.brief ?? "");
|
||||
run.hydrate(task.messages);
|
||||
} else {
|
||||
// 空记录(新初始化):清空当前会话,等宿主填任务 / 触发分析。
|
||||
run.reset();
|
||||
}
|
||||
emit("update:taskId", task.id);
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshHistory();
|
||||
if (props.taskId) {
|
||||
void loadTask(props.taskId);
|
||||
} else if (history.value.length) {
|
||||
// 没指定 taskId:默认回显最新一条聊天记录。
|
||||
void loadTask(history.value[0].id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
|
||||
:class="dark ? 'parallel-agents--dark' : ''"
|
||||
>
|
||||
<!-- 顶部表单与按钮已移除:任务名 / 简介 / 模型由宿主 props 传入,
|
||||
「重新分析 / 保存 / 停止」由宿主调用本组件 expose 出的方法。 -->
|
||||
|
||||
<!-- 错误提示(保留,便于宿主无需额外处理也能看到失败原因) -->
|
||||
<p
|
||||
v-if="errorMsg"
|
||||
class="pa-border shrink-0 border-b px-4 py-2 text-xs"
|
||||
style="color: var(--pa-rose)"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</p>
|
||||
|
||||
<!-- 席位状态条 -->
|
||||
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
|
||||
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
|
||||
</div>
|
||||
|
||||
<!-- 会话区:并行双列。本容器只负责限高(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 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 relative flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-center"
|
||||
>
|
||||
<p class="text-sm pa-text-muted">等待宿主触发「开始 / 重新分析」,并行调用各智能体。</p>
|
||||
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+5
-1
@@ -2,13 +2,14 @@
|
||||
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
|
||||
* 点击可切换主区显示哪两列。 */
|
||||
import { computed } from "vue";
|
||||
import type { SeatView } from "@/types";
|
||||
import type { SeatView } from "./types";
|
||||
import Avatar from "./Avatar.vue";
|
||||
|
||||
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
|
||||
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
|
||||
|
||||
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
|
||||
const clarifyCount = computed(() => props.seats.filter((s) => s.status === "clarify").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,6 +17,7 @@ const runningCount = computed(() => props.seats.filter((s) => s.status === "runn
|
||||
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
|
||||
并行 {{ seats.length }} 席
|
||||
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
|
||||
<span v-if="clarifyCount > 0" style="color: var(--pa-amber)">({{ clarifyCount }} 待协助)</span>
|
||||
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -46,12 +48,14 @@ const runningCount = computed(() => props.seats.filter((s) => s.status === "runn
|
||||
:class="{
|
||||
'pa-dot-running': seat.status === 'running',
|
||||
'pa-dot-done': seat.status === 'done',
|
||||
'pa-dot-clarify': seat.status === 'clarify',
|
||||
'pa-dot-error': seat.status === 'error',
|
||||
}"
|
||||
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
|
||||
>
|
||||
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
|
||||
<span v-else-if="seat.status === 'done'">✓</span>
|
||||
<span v-else-if="seat.status === 'clarify'">?</span>
|
||||
<span v-else-if="seat.status === 'error'">✕</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,61 @@
|
||||
# 并行多智能体面板(parallel-agents)
|
||||
|
||||
一个**自包含**的 Vue 3 可嵌入组件:输入任务名 + 简介,并行调用多个智能体,深色消息列表 +
|
||||
步骤条 + 协作卡片 + 保存回显 + 重新分析。
|
||||
|
||||
## 怎么用(复制即用)
|
||||
|
||||
把**整个 `parallel-agents/` 文件夹**拷到你项目的 `src/` 下,然后:
|
||||
|
||||
```ts
|
||||
import { ParallelAgentsPanel } from "@/parallel-agents";
|
||||
// 或: import { ParallelAgentsPanel } from "./parallel-agents";
|
||||
```
|
||||
|
||||
```vue
|
||||
<ParallelAgentsPanel
|
||||
ref="panel"
|
||||
:base-url="'http://localhost:8001'"
|
||||
:username="'demo'"
|
||||
:task-id="taskId || undefined"
|
||||
v-model:task-name="taskName"
|
||||
v-model:brief="brief"
|
||||
:model="model"
|
||||
:agents="agents"
|
||||
@agent-completed="onAgentCompleted"
|
||||
@completed="onCompleted"
|
||||
/>
|
||||
```
|
||||
|
||||
样式由入口 `index.ts` 自动 `import "./parallel-agents.css"`,**无需** Tailwind / 任何 CSS 框架。
|
||||
若你不走 `index.ts` 而是直接 import 组件,请自行引一次该 CSS。
|
||||
|
||||
## 依赖
|
||||
|
||||
- `vue` ^3.4
|
||||
- `markdown-it` ^14(正文 Markdown 渲染)—— `pnpm add markdown-it`
|
||||
|
||||
> 不再依赖 Tailwind:布局类已内置为 `parallel-agents.css` 末尾的「最小工具类子集」,全部作用域
|
||||
> 在根类 `.parallel-agents` 下,绝不外泄污染宿主页面。
|
||||
|
||||
## 文件结构(都在本文件夹内,可随意改源码)
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `ParallelAgentsPanel.vue` | 主组件(席位条 + 并行会话,暴露 start/save/cancel 等方法) |
|
||||
| `ParallelSeatStrip.vue` | 席位状态条(running / done / **clarify 待协助** / error) |
|
||||
| `MessageBubble.vue` | 单条消息气泡(头像 / 步骤条 / 正文 / 协作卡片) |
|
||||
| `MessageStepsCard.vue` · `StepIcon.vue` | 执行步骤条 / 思维链卡 + 图标 |
|
||||
| `ClarificationCard.vue` | 协作 / 澄清卡片(用户协助) |
|
||||
| `MarkdownContent.vue` | Markdown 正文渲染(markdown-it) |
|
||||
| `Avatar.vue` | 头像(机器人 / 真人 SVG) |
|
||||
| `useParallelRun.ts` | 并行运行编排(消费合并 SSE、按席位路由、待协助/并发协助) |
|
||||
| `api.ts` | 后端访问层(鉴权 + 扇出 SSE + 任务 CRUD) |
|
||||
| `types.ts` · `config.ts` · `avatar-constants.ts` · `step-display.ts` | 类型 / 配置 / 头像配色 / 步骤文案 |
|
||||
| `parallel-agents.css` | 作用域样式(深蓝深色 token + 语义类 + 工具类子集) |
|
||||
|
||||
## 关键状态
|
||||
|
||||
席位/结果状态 `SeatStatus`:`pending | running | done | clarify | error`。其中 **`clarify`** 表示
|
||||
该智能体在等用户协助(待确认),**不是已完成**——宿主据此判断「未完成」,多个智能体同时待协助
|
||||
时可分别协助、互不阻塞。
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { IconKey } from "@/lib/step-display";
|
||||
import type { IconKey } from "./step-display";
|
||||
defineProps<{ name: IconKey; size?: number }>();
|
||||
</script>
|
||||
|
||||
@@ -190,6 +190,21 @@ export async function createTask(
|
||||
return readJson<SavedTask>(res, "create task");
|
||||
}
|
||||
|
||||
/**
|
||||
* get-or-create:存在则返回(含 messages 回显),不存在则用该 id 初始化一条空记录。
|
||||
* 宿主传的 taskId 不固定,新 id 不再 404。幂等。
|
||||
*/
|
||||
export async function ensureTask(
|
||||
cfg: ApiConfig,
|
||||
payload: { id: string; title?: string; brief?: string; agents?: AgentInput[] },
|
||||
): Promise<SavedTask> {
|
||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/ensure`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: "", agents: [], ...payload }),
|
||||
});
|
||||
return readJson<SavedTask>(res, "ensure task");
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
cfg: ApiConfig,
|
||||
id: string,
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 并行多智能体面板 —— 自包含文件夹入口。
|
||||
*
|
||||
* 使用方式(把整个 parallel-agents/ 文件夹拷进你的项目 src 下即可):
|
||||
*
|
||||
* import { ParallelAgentsPanel } from "@/parallel-agents";
|
||||
* // 或相对路径: import { ParallelAgentsPanel } from "./parallel-agents";
|
||||
*
|
||||
* 样式会被这里自动引入(零依赖、作用域在 .parallel-agents 下,不污染宿主,无需 Tailwind)。
|
||||
* 仅依赖 vue 与 markdown-it(正文 Markdown 渲染)。详见同目录 README.md。
|
||||
*/
|
||||
import "./parallel-agents.css";
|
||||
|
||||
export { default as ParallelAgentsPanel } from "./ParallelAgentsPanel.vue";
|
||||
export type {
|
||||
AgentInput,
|
||||
AgentReply,
|
||||
AgentResult,
|
||||
ClarificationPayload,
|
||||
ClarOption,
|
||||
Dialogue,
|
||||
DialogueStep,
|
||||
SavedTask,
|
||||
SeatStatus,
|
||||
SeatView,
|
||||
StreamEvent,
|
||||
} from "./types";
|
||||
export type { ApiConfig } from "./api";
|
||||
@@ -0,0 +1,520 @@
|
||||
/* 并行多智能体面板样式 —— 零依赖、不污染宿主。
|
||||
*
|
||||
* 不再依赖 Tailwind:本文件自带一份「最小布局工具类子集」(文件末尾),全部作用域在根类
|
||||
* `.parallel-agents` 下,绝不外泄到宿主页面。其它项目把整个 parallel-agents/ 文件夹拷走、
|
||||
* 只 `import "./parallel-agents.css"` 即可,无需安装或配置 Tailwind。
|
||||
*
|
||||
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
|
||||
* 深色一致)。语义颜色用 `.pa-*` 类,布局用末尾的工具类子集。 */
|
||||
|
||||
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
|
||||
.parallel-agents {
|
||||
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
|
||||
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
|
||||
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
|
||||
--pa-accent: #1a2f5a; /* hover / 软高亮 */
|
||||
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
|
||||
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
|
||||
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
|
||||
--pa-input: #4a6491; /* 控件描边 */
|
||||
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
|
||||
--pa-primary-fg: #ffffff;
|
||||
--pa-chip-bg: #16335f; /* badge 底 */
|
||||
--pa-chip-fg: #4096ff; /* badge 文字 */
|
||||
--pa-amber: #f59e0b;
|
||||
--pa-emerald: #10b981;
|
||||
--pa-rose: #ef4444;
|
||||
|
||||
color: var(--pa-fg);
|
||||
background-color: var(--pa-bg);
|
||||
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,
|
||||
.parallel-agents *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.parallel-agents ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.parallel-agents ::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
|
||||
border-radius: 999px;
|
||||
}
|
||||
.parallel-agents ::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
|
||||
}
|
||||
|
||||
/* ── 语义颜色类(组件用这些上色,布局用文件末尾的工具类子集)──────────────────── */
|
||||
.parallel-agents .pa-bg {
|
||||
background-color: var(--pa-bg);
|
||||
}
|
||||
.parallel-agents .pa-card {
|
||||
background-color: var(--pa-card);
|
||||
}
|
||||
.parallel-agents .pa-surface {
|
||||
background-color: var(--pa-muted);
|
||||
}
|
||||
.parallel-agents .pa-surface-soft {
|
||||
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
|
||||
}
|
||||
.parallel-agents .pa-border {
|
||||
border-color: var(--pa-border);
|
||||
}
|
||||
.parallel-agents .pa-text {
|
||||
color: var(--pa-fg);
|
||||
}
|
||||
.parallel-agents .pa-text-muted {
|
||||
color: var(--pa-fg-muted);
|
||||
}
|
||||
.parallel-agents .pa-text-primary {
|
||||
color: var(--pa-primary);
|
||||
}
|
||||
|
||||
/* 输入框 / 文本域 */
|
||||
.parallel-agents .pa-input {
|
||||
background-color: var(--pa-card);
|
||||
border: 1px solid var(--pa-border);
|
||||
color: var(--pa-fg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.parallel-agents .pa-input::placeholder {
|
||||
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
|
||||
}
|
||||
.parallel-agents .pa-input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
|
||||
}
|
||||
|
||||
/* 主按钮 / 次按钮 */
|
||||
.parallel-agents .pa-btn-primary {
|
||||
background-color: var(--pa-primary);
|
||||
color: var(--pa-primary-fg);
|
||||
border-radius: 8px;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
.parallel-agents .pa-btn-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.parallel-agents .pa-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.parallel-agents .pa-btn-ghost {
|
||||
border: 1px solid var(--pa-border);
|
||||
color: var(--pa-fg);
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.parallel-agents .pa-btn-ghost:hover {
|
||||
background-color: var(--pa-accent);
|
||||
}
|
||||
.parallel-agents .pa-btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
|
||||
.parallel-agents .pa-badge {
|
||||
background-color: var(--pa-chip-bg);
|
||||
color: var(--pa-chip-fg);
|
||||
border: 1px solid var(--pa-border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 席位状态角标色 */
|
||||
.parallel-agents .pa-dot-running {
|
||||
background-color: var(--pa-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.parallel-agents .pa-dot-done {
|
||||
background-color: var(--pa-emerald);
|
||||
color: #fff;
|
||||
}
|
||||
.parallel-agents .pa-dot-error {
|
||||
background-color: var(--pa-rose);
|
||||
color: #fff;
|
||||
}
|
||||
.parallel-agents .pa-dot-clarify {
|
||||
background-color: var(--pa-amber);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 流式光标 + loading 点 */
|
||||
.parallel-agents .pa-cursor {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 13px;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
background: var(--pa-primary);
|
||||
animation: pa-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pa-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
.parallel-agents .pa-bounce {
|
||||
animation: pa-bounce 0.9s infinite;
|
||||
}
|
||||
@keyframes pa-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
@keyframes pa-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.parallel-agents .pa-spin {
|
||||
animation: pa-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes pa-ping {
|
||||
75%,
|
||||
100% {
|
||||
transform: scale(1.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.parallel-agents .pa-ping {
|
||||
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);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
.parallel-agents .pa-md p {
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
.parallel-agents .pa-md h1,
|
||||
.parallel-agents .pa-md h2,
|
||||
.parallel-agents .pa-md h3,
|
||||
.parallel-agents .pa-md h4 {
|
||||
font-weight: 700;
|
||||
margin: 0.7em 0 0.35em;
|
||||
color: var(--pa-fg);
|
||||
}
|
||||
.parallel-agents .pa-md h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.parallel-agents .pa-md h2 {
|
||||
font-size: 1.18em;
|
||||
}
|
||||
.parallel-agents .pa-md h3 {
|
||||
font-size: 1.06em;
|
||||
}
|
||||
.parallel-agents .pa-md ul,
|
||||
.parallel-agents .pa-md ol {
|
||||
margin: 0.35em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.parallel-agents .pa-md li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
.parallel-agents .pa-md a {
|
||||
color: var(--pa-chip-fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.parallel-agents .pa-md code {
|
||||
background: var(--pa-muted);
|
||||
border: 1px solid var(--pa-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.parallel-agents .pa-md pre {
|
||||
background: var(--pa-muted);
|
||||
border: 1px solid var(--pa-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75em;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.parallel-agents .pa-md pre code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.parallel-agents .pa-md blockquote {
|
||||
border-left: 3px solid var(--pa-border);
|
||||
padding-left: 0.75em;
|
||||
color: var(--pa-fg-muted);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.parallel-agents .pa-md table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
width: 100%;
|
||||
}
|
||||
.parallel-agents .pa-md th,
|
||||
.parallel-agents .pa-md td {
|
||||
border: 1px solid var(--pa-border);
|
||||
padding: 0.35em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
.parallel-agents .pa-md th {
|
||||
background: var(--pa-muted);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 最小布局工具类子集(替代 Tailwind)
|
||||
* 只实现本组件模板用到的类,全部作用域在 .parallel-agents 下,不影响宿主。
|
||||
* 维护提示:模板里新增布局类时,在此处补一条对应规则即可(或直接写 scoped <style>)。
|
||||
*
|
||||
* 放进 @layer:让无层级的 `.pa-*` 语义类(颜色/按钮/徽标)在同名属性冲突时仍**压过**这里的
|
||||
* 工具类,复刻原先 Tailwind utilities 在 @layer、pa-* 无层级时的优先级(如 pa-btn-primary
|
||||
* 的 8px 圆角压过 rounded-xl)。
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer pa-utilities {
|
||||
|
||||
/* display */
|
||||
.parallel-agents .flex { display: flex; }
|
||||
.parallel-agents .inline-flex { display: inline-flex; }
|
||||
.parallel-agents .inline-block { display: inline-block; }
|
||||
.parallel-agents .block { display: block; }
|
||||
.parallel-agents .grid { display: grid; }
|
||||
|
||||
/* flex */
|
||||
.parallel-agents .flex-col { flex-direction: column; }
|
||||
.parallel-agents .flex-1 { flex: 1 1 0%; }
|
||||
.parallel-agents .flex-wrap { flex-wrap: wrap; }
|
||||
.parallel-agents .items-center { align-items: center; }
|
||||
.parallel-agents .items-start { align-items: flex-start; }
|
||||
.parallel-agents .justify-center { justify-content: center; }
|
||||
.parallel-agents .justify-between { justify-content: space-between; }
|
||||
.parallel-agents .justify-end { justify-content: flex-end; }
|
||||
.parallel-agents .shrink-0 { flex-shrink: 0; }
|
||||
|
||||
/* grid */
|
||||
.parallel-agents .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||
@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; }
|
||||
.parallel-agents .absolute { position: absolute; }
|
||||
.parallel-agents .inset-0 { inset: 0; }
|
||||
.parallel-agents .left-1\/2 { left: 50%; }
|
||||
.parallel-agents .-bottom-0\.5 { bottom: -0.125rem; }
|
||||
.parallel-agents .-right-0\.5 { right: -0.125rem; }
|
||||
|
||||
/* sizing */
|
||||
.parallel-agents .w-full { width: 100%; }
|
||||
.parallel-agents .w-px { width: 1px; }
|
||||
.parallel-agents .h-full { height: 100%; }
|
||||
.parallel-agents .h-8 { height: 2rem; }
|
||||
.parallel-agents .min-w-0 { min-width: 0; }
|
||||
.parallel-agents .min-h-0 { min-height: 0; }
|
||||
.parallel-agents .min-h-16 { min-height: 4rem; }
|
||||
.parallel-agents .max-w-full { max-width: 100%; }
|
||||
.parallel-agents .max-h-60 { max-height: 15rem; }
|
||||
.parallel-agents .size-1 { width: 0.25rem; height: 0.25rem; }
|
||||
.parallel-agents .size-3\.5 { width: 0.875rem; height: 0.875rem; }
|
||||
|
||||
/* gap */
|
||||
.parallel-agents .gap-0\.5 { gap: 0.125rem; }
|
||||
.parallel-agents .gap-1 { gap: 0.25rem; }
|
||||
.parallel-agents .gap-1\.5 { gap: 0.375rem; }
|
||||
.parallel-agents .gap-2 { gap: 0.5rem; }
|
||||
.parallel-agents .gap-3 { gap: 0.75rem; }
|
||||
.parallel-agents .gap-4 { gap: 1rem; }
|
||||
|
||||
/* space-y(子元素纵向间距) */
|
||||
.parallel-agents .space-y-1 > * + * { margin-top: 0.25rem; }
|
||||
.parallel-agents .space-y-1\.5 > * + * { margin-top: 0.375rem; }
|
||||
.parallel-agents .space-y-2 > * + * { margin-top: 0.5rem; }
|
||||
|
||||
/* padding */
|
||||
.parallel-agents .p-0\.5 { padding: 0.125rem; }
|
||||
.parallel-agents .p-2 { padding: 0.5rem; }
|
||||
.parallel-agents .p-3 { padding: 0.75rem; }
|
||||
.parallel-agents .p-4 { padding: 1rem; }
|
||||
.parallel-agents .px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; }
|
||||
.parallel-agents .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.parallel-agents .px-2\.5 { padding-left: 0.625rem; padding-right: 0.625rem; }
|
||||
.parallel-agents .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.parallel-agents .px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.parallel-agents .py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
|
||||
.parallel-agents .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.parallel-agents .py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
|
||||
.parallel-agents .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.parallel-agents .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||
.parallel-agents .pt-0\.5 { padding-top: 0.125rem; }
|
||||
.parallel-agents .pb-2 { padding-bottom: 0.5rem; }
|
||||
|
||||
/* margin */
|
||||
.parallel-agents .mt-0\.5 { margin-top: 0.125rem; }
|
||||
.parallel-agents .mt-2 { margin-top: 0.5rem; }
|
||||
.parallel-agents .mt-3 { margin-top: 0.75rem; }
|
||||
.parallel-agents .mb-2 { margin-bottom: 0.5rem; }
|
||||
.parallel-agents .ml-2 { margin-left: 0.5rem; }
|
||||
.parallel-agents .-mx-px { margin-left: -1px; margin-right: -1px; }
|
||||
|
||||
/* border + radius */
|
||||
.parallel-agents .border { border-width: 1px; border-style: solid; }
|
||||
.parallel-agents .border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||
.parallel-agents .rounded { border-radius: 0.25rem; }
|
||||
.parallel-agents .rounded-md { border-radius: 0.375rem; }
|
||||
.parallel-agents .rounded-lg { border-radius: 0.5rem; }
|
||||
.parallel-agents .rounded-xl { border-radius: 0.75rem; }
|
||||
.parallel-agents .rounded-full { border-radius: 9999px; }
|
||||
|
||||
/* font-size(含任意值) */
|
||||
.parallel-agents .text-\[8px\] { font-size: 8px; }
|
||||
.parallel-agents .text-\[10px\] { font-size: 10px; }
|
||||
.parallel-agents .text-\[11px\] { font-size: 11px; }
|
||||
.parallel-agents .text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.parallel-agents .text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
|
||||
/* font-weight */
|
||||
.parallel-agents .font-normal { font-weight: 400; }
|
||||
.parallel-agents .font-medium { font-weight: 500; }
|
||||
.parallel-agents .font-semibold { font-weight: 600; }
|
||||
.parallel-agents .font-bold { font-weight: 700; }
|
||||
.parallel-agents .font-extrabold { font-weight: 800; }
|
||||
.parallel-agents .font-black { font-weight: 900; }
|
||||
|
||||
/* font 其它 */
|
||||
.parallel-agents .font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.parallel-agents .italic { font-style: italic; }
|
||||
.parallel-agents .uppercase { text-transform: uppercase; }
|
||||
.parallel-agents .tracking-wide { letter-spacing: 0.025em; }
|
||||
|
||||
/* line-height */
|
||||
.parallel-agents .leading-snug { line-height: 1.375; }
|
||||
.parallel-agents .leading-relaxed { line-height: 1.625; }
|
||||
|
||||
/* text-align */
|
||||
.parallel-agents .text-left { text-align: left; }
|
||||
.parallel-agents .text-center { text-align: center; }
|
||||
|
||||
/* 文本截断 / 换行 */
|
||||
.parallel-agents .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.parallel-agents .break-words { overflow-wrap: break-word; }
|
||||
.parallel-agents .line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* overflow */
|
||||
.parallel-agents .overflow-hidden { overflow: hidden; }
|
||||
.parallel-agents .overflow-auto { overflow: auto; }
|
||||
.parallel-agents .overflow-x-auto { overflow-x: auto; }
|
||||
.parallel-agents .overflow-y-auto { overflow-y: auto; }
|
||||
|
||||
/* 效果 / 变换 / 过渡 */
|
||||
.parallel-agents .opacity-60 { opacity: 0.6; }
|
||||
.parallel-agents .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
|
||||
.parallel-agents .resize-none { resize: none; }
|
||||
.parallel-agents .rotate-90 { transform: rotate(90deg); }
|
||||
.parallel-agents .transition {
|
||||
transition-property: color, background-color, border-color, fill, stroke, opacity,
|
||||
box-shadow, transform, filter, backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.parallel-agents .transition-transform {
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.parallel-agents .hover\:opacity-90:hover { opacity: 0.9; }
|
||||
|
||||
} /* @layer pa-utilities */
|
||||
File renamed without changes.
+19
-3
@@ -44,12 +44,15 @@ export interface Dialogue {
|
||||
clarification?: ClarificationPayload;
|
||||
}
|
||||
|
||||
/** 席位 / 结果的状态。`clarify` = 智能体在等用户协助(待确认),不是已完成。 */
|
||||
export type SeatStatus = "pending" | "running" | "done" | "clarify" | "error";
|
||||
|
||||
/** 并行席位的视图状态(席位状态条用)。 */
|
||||
export interface SeatView {
|
||||
agentId: string;
|
||||
name: string;
|
||||
avatarType: string;
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
status: SeatStatus;
|
||||
/** 关联的 Dialogue.id。 */
|
||||
bubbleId: number;
|
||||
}
|
||||
@@ -81,13 +84,26 @@ 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;
|
||||
/** 该智能体模型返回的正文(Markdown)。 */
|
||||
content: string;
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
/** `clarify` 表示在等用户协助(待确认),宿主据此判断「未完成」。 */
|
||||
status: SeatStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回传给宿主的「已完成」结果(agent-completed / completed 事件用):只含**智能体名称 + 模型返回
|
||||
* 结果**(agent_id 便于识别)。不带 status —— 因为只在真正完成(done)时才回传,本身即代表已完成。
|
||||
*/
|
||||
export interface AgentReply {
|
||||
agent_id: string;
|
||||
/** 智能体名称(显示名)。 */
|
||||
name: string;
|
||||
/** 模型返回结果(正文 Markdown)。 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
|
||||
+96
-18
@@ -3,9 +3,9 @@
|
||||
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
|
||||
*/
|
||||
import { ref, shallowRef } from "vue";
|
||||
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types";
|
||||
import { getAvatarTypeFor } from "@/lib/avatar-constants";
|
||||
import { streamParallelAgents, type ApiConfig } from "@/api";
|
||||
import type { AgentInput, Dialogue, SeatStatus, SeatView, StreamEvent } from "./types";
|
||||
import { getAvatarTypeFor } from "./avatar-constants";
|
||||
import { streamParallelAgents, type ApiConfig } from "./api";
|
||||
|
||||
function nowTime(): string {
|
||||
const d = new Date();
|
||||
@@ -18,12 +18,47 @@ function nextId(): number {
|
||||
return _seq++;
|
||||
}
|
||||
|
||||
export function useParallelRun() {
|
||||
export interface ParallelRunOptions {
|
||||
/** 单个智能体进入终态(完成 / 待确认 / 出错)时回调一次,用于「一分析完就回传」。 */
|
||||
onAgentSettled?: (agentId: string, status: SeatView["status"]) => void;
|
||||
}
|
||||
|
||||
export function useParallelRun(opts: ParallelRunOptions = {}) {
|
||||
const dialogues = ref<Dialogue[]>([]);
|
||||
const seats = ref<SeatView[]>([]);
|
||||
const running = ref(false);
|
||||
const abortRef = shallowRef<AbortController | null>(null);
|
||||
|
||||
// 正在「单席位重跑」(用户协助后继续)的 agentId 集合。澄清卡片的禁用态按席位取这里,
|
||||
// 而不是全局 running —— 这样多个智能体都待协助时,协助 A 不会禁用 B/C 的卡片。
|
||||
const busy = ref<Set<string>>(new Set());
|
||||
// 每个重跑各自的 AbortController(支持多席位并发协助 + 统一取消)。
|
||||
const acByAgent = new Map<string, AbortController>();
|
||||
function setBusy(agentId: string, on: boolean) {
|
||||
const next = new Set(busy.value);
|
||||
if (on) next.add(agentId);
|
||||
else next.delete(agentId);
|
||||
busy.value = next;
|
||||
}
|
||||
|
||||
// 本轮已「结算」(回调过 onAgentSettled)的 agentId,去重,避免重复回传。
|
||||
const settled = new Set<string>();
|
||||
function settleAgent(agentId: string, status: SeatView["status"]) {
|
||||
if (settled.has(agentId)) return;
|
||||
settled.add(agentId);
|
||||
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;
|
||||
@@ -48,6 +83,11 @@ export function useParallelRun() {
|
||||
requestAnimationFrame(flushPending);
|
||||
}
|
||||
|
||||
// 断流兜底时把席位状态归一为「终态」:保留待协助 / 出错,其余(running/pending)算完成。
|
||||
function settleStatusOf(status: SeatStatus): SeatStatus {
|
||||
return status === "clarify" || status === "error" ? status : "done";
|
||||
}
|
||||
|
||||
function setSeatStatus(agentId: string, status: SeatView["status"]) {
|
||||
const s = seats.value.find((x) => x.agentId === agentId);
|
||||
if (s) s.status = status;
|
||||
@@ -87,19 +127,24 @@ export function useParallelRun() {
|
||||
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 = "已完成";
|
||||
}
|
||||
setSeatStatus(ev.agent_id, "done");
|
||||
dialogues.value = [...dialogues.value];
|
||||
settleAgent(ev.agent_id, "done");
|
||||
break;
|
||||
}
|
||||
case "agent_clarification": {
|
||||
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,
|
||||
@@ -111,12 +156,15 @@ export function useParallelRun() {
|
||||
d.streaming = false;
|
||||
d.confidence = "待确认";
|
||||
}
|
||||
setSeatStatus(ev.agent_id, "done");
|
||||
// 待协助是独立状态,不是 done —— 否则席位条显示 ✓、宿主判定已完成。
|
||||
setSeatStatus(ev.agent_id, "clarify");
|
||||
dialogues.value = [...dialogues.value];
|
||||
settleAgent(ev.agent_id, "clarify");
|
||||
break;
|
||||
}
|
||||
case "agent_error": {
|
||||
flushPending();
|
||||
rerunPrefix.delete(ev.agent_id); // 出错就丢弃前缀(正文已含历史+已流出的增量)
|
||||
const d = findDialogue(ev.agent_id);
|
||||
if (d) {
|
||||
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
|
||||
@@ -125,6 +173,7 @@ export function useParallelRun() {
|
||||
}
|
||||
setSeatStatus(ev.agent_id, "error");
|
||||
dialogues.value = [...dialogues.value];
|
||||
settleAgent(ev.agent_id, "error");
|
||||
break;
|
||||
}
|
||||
case "all_done":
|
||||
@@ -180,14 +229,22 @@ export function useParallelRun() {
|
||||
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 = "已完成";
|
||||
}
|
||||
}
|
||||
dialogues.value = [...dialogues.value];
|
||||
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态,保留待协助/出错)。
|
||||
// 同样跳过 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;
|
||||
}
|
||||
@@ -195,6 +252,9 @@ export function useParallelRun() {
|
||||
|
||||
function cancel() {
|
||||
abortRef.value?.abort();
|
||||
for (const ac of acByAgent.values()) ac.abort();
|
||||
acByAgent.clear();
|
||||
busy.value = new Set();
|
||||
running.value = false;
|
||||
}
|
||||
|
||||
@@ -204,13 +264,23 @@ export function useParallelRun() {
|
||||
*/
|
||||
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) 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 = "并行执行中";
|
||||
@@ -218,9 +288,11 @@ export function useParallelRun() {
|
||||
setSeatStatus(args.agentId, "running");
|
||||
dialogues.value = [...dialogues.value];
|
||||
|
||||
running.value = true;
|
||||
settled.delete(args.agentId); // 该席位重跑,允许再次回传
|
||||
// 不动全局 running —— 用按席位的 busy,这样可同时协助多个待确认的智能体。
|
||||
setBusy(args.agentId, true);
|
||||
const ac = new AbortController();
|
||||
abortRef.value = ac;
|
||||
acByAgent.set(args.agentId, ac);
|
||||
try {
|
||||
await streamParallelAgents(
|
||||
cfg,
|
||||
@@ -235,8 +307,10 @@ export function useParallelRun() {
|
||||
if (dd.confidence === "并行执行中") dd.confidence = "已完成";
|
||||
}
|
||||
dialogues.value = [...dialogues.value];
|
||||
running.value = false;
|
||||
abortRef.value = null;
|
||||
const st = seats.value.find((x) => x.agentId === args.agentId);
|
||||
settleAgent(args.agentId, settleStatusOf(st?.status ?? "done"));
|
||||
acByAgent.delete(args.agentId);
|
||||
setBusy(args.agentId, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,11 +324,13 @@ export function useParallelRun() {
|
||||
restored.forEach((d) => {
|
||||
bubbleByAgent.set(d.agentId, d.id);
|
||||
if (!seatMap.has(d.agentId)) {
|
||||
const status: SeatStatus =
|
||||
d.confidence === "出错" ? "error" : d.clarification ? "clarify" : "done";
|
||||
seatMap.set(d.agentId, {
|
||||
agentId: d.agentId,
|
||||
name: d.sender,
|
||||
avatarType: d.avatarType,
|
||||
status: d.confidence === "出错" ? "error" : "done",
|
||||
status,
|
||||
bubbleId: d.id,
|
||||
});
|
||||
}
|
||||
@@ -266,9 +342,11 @@ export function useParallelRun() {
|
||||
cancel();
|
||||
pending.clear();
|
||||
bubbleByAgent.clear();
|
||||
settled.clear();
|
||||
rerunPrefix.clear();
|
||||
dialogues.value = [];
|
||||
seats.value = [];
|
||||
}
|
||||
|
||||
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
|
||||
return { dialogues, seats, running, busy, start, cancel, hydrate, reset, rerunAgent };
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/* 并行多智能体面板样式。
|
||||
*
|
||||
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
|
||||
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
|
||||
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类,
|
||||
* 主题色用下面的 `.pa-*` 语义类。 */
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
|
||||
.parallel-agents {
|
||||
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
|
||||
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
|
||||
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
|
||||
--pa-accent: #1a2f5a; /* hover / 软高亮 */
|
||||
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
|
||||
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
|
||||
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
|
||||
--pa-input: #4a6491; /* 控件描边 */
|
||||
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
|
||||
--pa-primary-fg: #ffffff;
|
||||
--pa-chip-bg: #16335f; /* badge 底 */
|
||||
--pa-chip-fg: #4096ff; /* badge 文字 */
|
||||
--pa-amber: #f59e0b;
|
||||
--pa-emerald: #10b981;
|
||||
--pa-rose: #ef4444;
|
||||
|
||||
color: var(--pa-fg);
|
||||
background-color: var(--pa-bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.parallel-agents *,
|
||||
.parallel-agents *::before,
|
||||
.parallel-agents *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.parallel-agents ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.parallel-agents ::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
|
||||
border-radius: 999px;
|
||||
}
|
||||
.parallel-agents ::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
|
||||
}
|
||||
|
||||
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
|
||||
.parallel-agents .pa-bg {
|
||||
background-color: var(--pa-bg);
|
||||
}
|
||||
.parallel-agents .pa-card {
|
||||
background-color: var(--pa-card);
|
||||
}
|
||||
.parallel-agents .pa-surface {
|
||||
background-color: var(--pa-muted);
|
||||
}
|
||||
.parallel-agents .pa-surface-soft {
|
||||
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
|
||||
}
|
||||
.parallel-agents .pa-border {
|
||||
border-color: var(--pa-border);
|
||||
}
|
||||
.parallel-agents .pa-text {
|
||||
color: var(--pa-fg);
|
||||
}
|
||||
.parallel-agents .pa-text-muted {
|
||||
color: var(--pa-fg-muted);
|
||||
}
|
||||
.parallel-agents .pa-text-primary {
|
||||
color: var(--pa-primary);
|
||||
}
|
||||
|
||||
/* 输入框 / 文本域 */
|
||||
.parallel-agents .pa-input {
|
||||
background-color: var(--pa-card);
|
||||
border: 1px solid var(--pa-border);
|
||||
color: var(--pa-fg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.parallel-agents .pa-input::placeholder {
|
||||
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
|
||||
}
|
||||
.parallel-agents .pa-input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
|
||||
}
|
||||
|
||||
/* 主按钮 / 次按钮 */
|
||||
.parallel-agents .pa-btn-primary {
|
||||
background-color: var(--pa-primary);
|
||||
color: var(--pa-primary-fg);
|
||||
border-radius: 8px;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
.parallel-agents .pa-btn-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.parallel-agents .pa-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.parallel-agents .pa-btn-ghost {
|
||||
border: 1px solid var(--pa-border);
|
||||
color: var(--pa-fg);
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.parallel-agents .pa-btn-ghost:hover {
|
||||
background-color: var(--pa-accent);
|
||||
}
|
||||
.parallel-agents .pa-btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
|
||||
.parallel-agents .pa-badge {
|
||||
background-color: var(--pa-chip-bg);
|
||||
color: var(--pa-chip-fg);
|
||||
border: 1px solid var(--pa-border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 席位状态角标色 */
|
||||
.parallel-agents .pa-dot-running {
|
||||
background-color: var(--pa-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.parallel-agents .pa-dot-done {
|
||||
background-color: var(--pa-emerald);
|
||||
color: #fff;
|
||||
}
|
||||
.parallel-agents .pa-dot-error {
|
||||
background-color: var(--pa-rose);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 流式光标 + loading 点 */
|
||||
.parallel-agents .pa-cursor {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 13px;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
background: var(--pa-primary);
|
||||
animation: pa-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pa-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
.parallel-agents .pa-bounce {
|
||||
animation: pa-bounce 0.9s infinite;
|
||||
}
|
||||
@keyframes pa-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
@keyframes pa-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.parallel-agents .pa-spin {
|
||||
animation: pa-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes pa-ping {
|
||||
75%,
|
||||
100% {
|
||||
transform: scale(1.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.parallel-agents .pa-ping {
|
||||
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
|
||||
.parallel-agents .pa-md {
|
||||
color: var(--pa-fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
.parallel-agents .pa-md p {
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
.parallel-agents .pa-md h1,
|
||||
.parallel-agents .pa-md h2,
|
||||
.parallel-agents .pa-md h3,
|
||||
.parallel-agents .pa-md h4 {
|
||||
font-weight: 700;
|
||||
margin: 0.7em 0 0.35em;
|
||||
color: var(--pa-fg);
|
||||
}
|
||||
.parallel-agents .pa-md h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.parallel-agents .pa-md h2 {
|
||||
font-size: 1.18em;
|
||||
}
|
||||
.parallel-agents .pa-md h3 {
|
||||
font-size: 1.06em;
|
||||
}
|
||||
.parallel-agents .pa-md ul,
|
||||
.parallel-agents .pa-md ol {
|
||||
margin: 0.35em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.parallel-agents .pa-md li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
.parallel-agents .pa-md a {
|
||||
color: var(--pa-chip-fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.parallel-agents .pa-md code {
|
||||
background: var(--pa-muted);
|
||||
border: 1px solid var(--pa-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.parallel-agents .pa-md pre {
|
||||
background: var(--pa-muted);
|
||||
border: 1px solid var(--pa-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75em;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.parallel-agents .pa-md pre code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.parallel-agents .pa-md blockquote {
|
||||
border-left: 3px solid var(--pa-border);
|
||||
padding-left: 0.75em;
|
||||
color: var(--pa-fg-muted);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.parallel-agents .pa-md table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
width: 100%;
|
||||
}
|
||||
.parallel-agents .pa-md th,
|
||||
.parallel-agents .pa-md td {
|
||||
border: 1px solid var(--pa-border);
|
||||
padding: 0.35em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
.parallel-agents .pa-md th {
|
||||
background: var(--pa-muted);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// 两种构建:
|
||||
// - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。
|
||||
@@ -10,7 +9,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isLib = mode === "lib";
|
||||
return {
|
||||
plugins: [vue(), tailwindcss()],
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user