Compare commits

...
Author SHA1 Message Date
董宏凯 bb09c5991b 代码优化 2026-07-19 16:08:40 +08:00
董宏凯 760b88d6f1 feat: refresh workspace UI and agent visuals 2026-07-16 00:06:40 +08:00
73 changed files with 9399 additions and 7144 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
---
name: deerflow-ui-guard
description: Improve or review UI in the DeerFlow Vite/React frontend while preserving its light, dark-blue, and light-blue themes, Radix/shadcn-style primitives, Chinese enterprise density, embedded routes, and Chrome 109 compatibility. Use for visual refinements, new frontend pages/components, UI reviews, token cleanup, or design-system work under frontend-web/.
---
# DeerFlow UI Guard
Use this skill only in this repository. Preserve the product's existing visual system; do not replace it with a generic SaaS template or a new component library.
## Read the relevant sources first
1. Read `references/project-sources.md`, then the relevant theme specification.
2. Inspect the page and the nearest existing components before proposing or changing UI.
3. Read `frontend-web/src/styles/globals.css` and the affected primitives under `frontend-web/src/components/ui/` before changing tokens, shared styles, or variants.
## Design and implementation rules
- Reuse the existing Radix/shadcn-style primitives, `lucide-react`, Tailwind v4, and established CSS variables. Do not add Ant Design, another component system, or a visual-effects package unless the user explicitly asks.
- Keep all three themes correct: light/red, dark/blue, and light-blue. Prefer semantic classes and existing variables over raw color values. When a new token is necessary, define matching values for every theme.
- Preserve Chinese enterprise-product density and task completion speed. Make hierarchy, primary action, status, empty states, error recovery, and keyboard focus clearer before adding decoration.
- Keep the shell, deep links, authentication, embed/isolation behavior, sidebar menu semantics, and responsive layout unchanged unless the request explicitly targets them.
- Avoid unscoped global overrides and `!important`; place page-specific styling beside the feature and make selectors local.
- Support the Chrome 109 constraint. Do not introduce unguarded `oklch()`, `oklab()`, `lab()`, `lch()`, or `color-mix()` values.
- Preserve accessible names, keyboard interaction, visible `:focus-visible` states, sufficient contrast, and reduced-motion behavior for new or changed controls.
## Delivery workflow
1. State which existing theme and UI primitives the work will reuse.
2. Make the smallest coherent change; do not perform an unrelated visual sweep.
3. Validate with `pnpm typecheck`; when CSS or built UI changes, also run `pnpm build` and `pnpm check:chrome109-css` from `frontend-web/` when practical.
4. If a browser is available, inspect screenshots in light, dark, and light-blue modes. Otherwise say that visual verification remains outstanding.
5. For a review-only request, use `web-design-guidelines` as an additional quality gate when it is available, then distinguish confirmed issues from visual checks that still need a browser.
@@ -0,0 +1,4 @@
interface:
display_name: "DeerFlow UI Guard"
short_description: "Apply DeerFlow themes and UI safety checks."
default_prompt: "Use $deerflow-ui-guard to improve this DeerFlow UI without breaking its themes or legacy-browser support."
@@ -0,0 +1,23 @@
# DeerFlow UI sources
Read only the files relevant to the requested surface.
## Canonical themes
- Light/red: `frontend-web/docs/浅红色UI规范/README.md` and its focused section files.
- Dark/blue: `frontend-web/docs/ui规范-深蓝/README.md` and its focused section files.
- Light-blue: `frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md`.
- Runtime tokens and browser fallbacks: `frontend-web/src/styles/globals.css`.
## Shared implementation
- UI primitives: `frontend-web/src/components/ui/`.
- Theme selection: `frontend-web/src/contexts/ThemeContext.tsx` and `frontend-web/src/App.tsx`.
- Application shell and embed behavior: `frontend-web/src/pages/PageRoutes.tsx`.
- Dynamic navigation: `frontend-web/src/core/page-layout/use-sidebar-menu.ts`.
## Required constraints
- Use `pnpm` in `frontend-web/`.
- Retain Chrome 109 compatibility; validate CSS with `pnpm check:chrome109-css` after a build.
- Do not move or replace public assets, route behavior, or authentication as part of a visual-only change.
+6
View File
@@ -78,6 +78,12 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API # Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json offline-backend-20260512/backend/skill_evolution.json
skills/ skills/
# Keep the project-local Codex UI guard under version control. The broad
# skills/ rule above still excludes runtime skill directories elsewhere.
!.codex/
!.codex/skills/
!.codex/skills/deerflow-ui-guard/
!.codex/skills/deerflow-ui-guard/**
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
+48
View File
@@ -0,0 +1,48 @@
# Design QA — skills card list
## Scope
Only the skill-card list presentation was changed. The page header, summary cards, filters, top actions, sidebar, and existing interactions were left intact.
## Visual evidence
- Source visual truth: `C:\Users\admin\AppData\Local\Temp\codex-clipboard-8ca03817-ebd4-43e1-9d7c-2119e87d331c.png`
- Implementation URL: `http://localhost:5173/#/page/workspace/skills`
- Implementation capture: `F:\react01\deeflow-code\deerflow-server\skills-card-list-implementation.png`
- Focused source/implementation comparison: `F:\react01\deeflow-code\deerflow-server\skills-card-list-comparison.png`
- Viewport and state: Edge, desktop light theme, default list mode. The supplied reference contains three sample skills; the live implementation currently exposes one skill, so the comparison focuses on the first card row rather than fabricated data.
## Findings
No actionable P0, P1, or P2 visual issue remains within the requested list-card scope.
- **Hierarchy and layout:** the card now begins with a large semantic skill icon, then groups name, existing status badges, description, and publisher before the existing tag, use-count, state, activity, and action areas. The five-column grid keeps the important skill identity visible while preserving the original live fields.
- **Typography and spacing:** the title, description, metadata, and tags use the existing product type scale with a compact 12px card radius and consistent horizontal rhythm. The row remains responsive and collapses cleanly on narrower widths.
- **Color and surfaces:** category icons use restrained solid blue/green/violet/amber surfaces, tags remain subdued, and enabled status uses a quiet green pill. Borders and hover surfaces follow the existing neutral card treatment; no gradients were introduced.
- **Image and icon fidelity:** the source uses symbolic skill artwork. The implementation uses the closest existing Lucide workflow, globe, file, and upload icons in colored circular surfaces rather than adding screenshot-derived artwork or a new custom asset.
- **Feature fidelity:** all pre-existing action controls, view modes, and list metadata stay visible. The reference's toggle was not copied as a fake interactive control because this page had no enable/disable mutation; its existing `enabled` data is shown as a read-only status pill instead.
## Comparison history
1. **[P1, fixed] The original live skill row had little identity or information hierarchy.**
- Earlier state: title, description, metadata, and the action cluster were visually detached, with no category icon or clear scanning order.
- Fix: rebuilt only the list/card row layout around existing data and handlers, added semantic colored icon surfaces, compact state/use/activity columns, and a unified action cluster.
- Evidence: `skills-card-list-comparison.png` contains the reference card row and the live implementation card row in one focused comparison.
## Interaction and engineering checks
- Edge interaction check: passed. The live row retains 7 existing action buttons; opening the details action displays its dialog.
- View-mode behavior: passed. The existing list/card view control still switches to card mode (`aria-pressed=true`); the card view uses the same restyled visual language.
- Browser runtime: two guest-bootstrap resource requests returned `401 Unauthorized` after reload. They are unrelated to the skill-card styling and did not prevent the list, details dialog, or view-mode control from working.
- `pnpm build`: passed. The project still emits the pre-existing `/images/deer.svg` runtime-resolution warning.
- `git diff --check`: passed; only existing line-ending notices were emitted for unrelated files.
## Implementation checklist
- [x] Skill-card list restyled without changing page chrome or filtering controls
- [x] Existing skill actions, metadata, and view modes retained
- [x] Live `enabled` data displayed without inventing a new mutation control
- [x] Source and implementation reviewed in a single focused comparison image
- [x] Edge interaction and production build verified
final result: passed
Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
<title id="title">CM 助手</title>
<defs>
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#5DB5FF"/>
<stop offset="1" stop-color="#328AF2"/>
</linearGradient>
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
<stop stop-color="#62B2FF"/>
<stop offset="1" stop-color="#4B98F4"/>
</linearGradient>
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#2C7CF0"/>
<stop offset="1" stop-color="#1F5EDA"/>
</linearGradient>
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#276DE3"/>
<stop offset="1" stop-color="#4E9DF5"/>
</linearGradient>
</defs>
<!-- The mark is drawn as four independent folded faces. Together they form
the open C-shaped cube used in the product reference. -->
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,90 @@
"use client";
import { BotIcon, SparklesIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type AgentBrandIconProps = {
className?: string;
size?: "nav" | "sm" | "md" | "lg";
};
const SIZE_STYLES = {
nav: {
frame: "size-[22px] rounded-md",
bot: "size-3.5",
badge: "-right-1 -top-1 size-2.5",
sparkle: "size-1.5",
},
sm: {
frame: "size-10 rounded-full",
bot: "size-[18px]",
badge: "-right-1 -top-1 size-3.5",
sparkle: "size-2",
},
md: {
frame: "size-12 rounded-full",
bot: "size-6",
badge: "-right-1 -top-1 size-4",
sparkle: "size-2.5",
},
lg: {
frame: "size-16 rounded-full",
bot: "size-7",
badge: "-right-1 -top-1 size-[18px]",
sparkle: "size-3",
},
} as const;
export function AgentBrandIcon({
className,
size = "md",
}: AgentBrandIconProps) {
const styles = SIZE_STYLES[size];
const showBadge = size === "nav";
if (size !== "nav") {
return (
<span
aria-hidden="true"
data-agent-brand-icon={size}
className={cn(
"relative inline-flex shrink-0 overflow-hidden rounded-full",
styles.frame,
className,
)}
>
<img
src="/agent-avatar.png"
alt=""
className="size-full object-contain"
draggable={false}
/>
</span>
);
}
return (
<span
aria-hidden="true"
data-agent-brand-icon={size}
className={cn(
"relative inline-flex shrink-0 items-center justify-center border border-blue-100 bg-[radial-gradient(circle_at_35%_30%,#ffffff_0%,#edf4ff_46%,#dce9ff_100%)] text-[#1f5fdb] shadow-[0_8px_22px_rgba(37,99,235,0.13),inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-blue-400/30 dark:bg-blue-500/20 dark:text-blue-300",
styles.frame,
className,
)}
>
<BotIcon className={styles.bot} strokeWidth={1.9} />
{showBadge && (
<span
className={cn(
"absolute flex items-center justify-center rounded-full bg-gradient-to-br from-[#4b91ff] to-[#1858db] text-white shadow-sm ring-2 ring-white dark:ring-slate-950",
styles.badge,
)}
>
<SparklesIcon className={styles.sparkle} strokeWidth={2.4} />
</span>
)}
</span>
);
}
@@ -1,13 +1,13 @@
"use client"; "use client";
import { import {
BotIcon,
ClockIcon, ClockIcon,
CopyIcon, CopyIcon,
EyeOffIcon, EyeOffIcon,
GlobeIcon, GlobeIcon,
LockIcon, LockIcon,
MessageSquareIcon, MessageSquareIcon,
MoreHorizontalIcon,
PinIcon, PinIcon,
ShieldIcon, ShieldIcon,
SparklesIcon, SparklesIcon,
@@ -46,6 +46,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@@ -60,6 +67,7 @@ import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog"; import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector"; import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -551,7 +559,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{isOwned && ( {isOwned && (
<Badge <Badge
variant="default" variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs" className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
> >
我的 我的
</Badge> </Badge>
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{isFromOthers && ( {isFromOthers && (
<Badge <Badge
variant="outline" variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs" className="border-blue-200 bg-blue-50/60 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-500/10 dark:text-blue-300"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"} title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
> >
来自他人 来自他人
@@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && ( {(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge <Badge
variant="outline" variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs" className="border-sky-200 bg-sky-50/60 text-xs text-sky-700 dark:border-sky-800 dark:bg-sky-500/10 dark:text-sky-300"
title="该智能体已对所有用户可见" title="该智能体已对所有用户可见"
> >
<GlobeIcon className="size-3" /> <GlobeIcon className="size-3" />
@@ -664,7 +672,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
...(agent.tool_groups ?? []).map((group) => ({ ...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`, key: `tg:${group}`,
node: ( node: (
<Badge key={`tg:${group}`} variant="outline" className="text-xs"> <Badge
key={`tg:${group}`}
variant="outline"
className="border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
>
{desensitize(group)} {desensitize(group)}
</Badge> </Badge>
), ),
@@ -675,7 +687,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<Badge <Badge
key={`sk:${skill}`} key={`sk:${skill}`}
variant="secondary" variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs" className="cursor-pointer border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
title={t.settings.skills.viewDetailAction} title={t.settings.skills.viewDetailAction}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -825,78 +837,179 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const chatButton = ( const chatButton = (
<Button <Button
variant={isList ? "default" : "outline"}
size="sm" size="sm"
className={cn(isList ? "shrink-0" : "w-full")} className={cn(
isList
? "shrink-0 rounded-xl bg-[#1f63e9] px-4 font-medium text-white hover:bg-[#1857d4]"
: "h-11 w-full rounded-lg border-[#b9d1ff] bg-white font-medium text-[#135ee8] shadow-none hover:border-[#79a8ff] hover:bg-[#f7faff] hover:text-[#135ee8] dark:border-blue-700 dark:bg-slate-900 dark:text-blue-300",
)}
onClick={handleChat} onClick={handleChat}
> >
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" /> <MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat} 开始对话
</Button> </Button>
); );
const cardStatusBadge = agent.is_private ? (
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
私密
</Badge>
) : agent.published || isBuiltin ? (
<Badge className="border-0 bg-blue-50 px-2.5 py-1 text-xs font-normal text-blue-600 hover:bg-blue-50 dark:bg-blue-500/15 dark:text-blue-300">
<span className="mr-1.5 size-1.5 rounded-full bg-blue-500" />
已发布
</Badge>
) : (
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
运行中
</Badge>
);
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
const cardActionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
title="更多操作"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canFavorite && (
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
{isFavorite ? "取消收藏" : "收藏到我的"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
{isPinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
<SparklesIcon className="size-4" />
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
<TagsIcon className="size-4" />
{t.adminTags.manageTags}
</DropdownMenuItem>
{isOwned && (
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
{agent.published ? "取消发布" : "发布"}
</DropdownMenuItem>
)}
{(isOwned || isAdmin) && (
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
<ShieldIcon className="size-4" />
{agent.is_private ? "移出私密广场" : "放入私密广场"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
<CopyIcon className="size-4" />
复制智能体
</DropdownMenuItem>
{canEdit && (
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<SquarePenIcon className="size-4" />
编辑智能体
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" />
{t.agents.delete}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
const cardLayout = ( const cardLayout = (
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"> <Card
<CardHeader className="min-w-0 pb-2"> className={cn(
<div className="flex min-w-0 items-start justify-between gap-2"> "group flex min-h-[366px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-[#b8d1ff] hover:!bg-[linear-gradient(135deg,rgba(239,246,255,0.92)_0%,rgba(255,255,255,0.98)_48%,#fff_100%)] hover:!ring-1 hover:!ring-[#e2edff] hover:shadow-[0_8px_24px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900",
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"> agent.featured_order != null && "border-blue-300 ring-1 ring-blue-100 dark:border-blue-700 dark:ring-blue-900/40",
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"> )}
<BotIcon className="h-5 w-5" /> >
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
{agent.featured_order != null ? (
<>
<div className="mb-1 flex h-7 items-center justify-between">
<Badge className="border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:border-blue-800 dark:bg-blue-500/15 dark:text-blue-300">
<SparklesIcon className="mr-1 size-3.5 fill-current" />
推荐
</Badge>
{cardActionsMenu}
</div> </div>
<div className="min-w-0 flex-1 overflow-hidden"> <AgentBrandIcon size="lg" />
</>
) : (
<div className="flex items-start justify-between">
<AgentBrandIcon size="lg" />
{cardActionsMenu}
</div>
)}
<CardTitle <CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap" className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
title={desensitize(agent.name)} title={desensitize(agent.name)}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</CardTitle> </CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{statusBadges}
</div>
</div>
{agent.description && ( {agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm"> <CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
{desensitize(agent.description)} {desensitize(agent.description)}
</CardDescription> </CardDescription>
)} )}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader> </CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2"> <CardContent className="px-5 pt-0 pb-1.5">
<TagChipList tags={agent.tags!} /> <TagChipList tags={agent.tags!} />
</CardContent> </CardContent>
)} )}
{hasSkillsOrTools && ( {hasSkillsOrTools && (
<CardContent className="pt-0 pb-2"> <CardContent className="px-5 pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} /> <ClampedBadgeRow items={skillBadgeItems} />
</CardContent> </CardContent>
)} )}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2"> <CardFooter className="mt-auto flex flex-col items-stretch gap-10 px-5 pt-2 pb-4">
{showActionRow && ( <div className="flex min-w-0 items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-1"> {cardStatusBadge}
{actionButtons} <span
className="min-w-0 truncate text-xs text-slate-400"
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
>
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
</span>
</div> </div>
)}
{chatButton} {chatButton}
</CardFooter> </CardFooter>
</Card> </Card>
); );
const listLayout = ( const listLayout = (
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"> <div className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-blue-200 hover:shadow-[0_12px_32px_rgba(37,99,235,0.09)] dark:border-slate-800 dark:bg-slate-900">
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"> <AgentBrandIcon className="agent-avatar" size="md" />
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
BotIcon,
CheckIcon, CheckIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
@@ -43,6 +42,7 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
@@ -520,9 +520,7 @@ export function AgentDetailPanel({
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */} {/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
<section className="bg-card/60 rounded-xl border p-4"> <section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1"> <AgentBrandIcon className="agent-avatar" size="lg" />
<BotIcon className="text-primary size-6" />
</div>
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? ( {editing && draft ? (
<Input <Input
@@ -1,6 +1,15 @@
"use client"; "use client";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react"; import {
ArrowDownUpIcon,
BotIcon,
Loader2,
LockIcon,
PlusIcon,
SearchIcon,
ShieldIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -10,17 +19,17 @@ import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination"; import { Pagination } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus, usePublishSquares } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card"; import { AgentCard } from "./agent-card";
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
type AgentStatusFilter = "all" | "published" | "draft" | "private" | "builtin";
// Within a single tab the verified password is kept in sessionStorage so a // Within a single tab the verified password is kept in sessionStorage so a
// refresh / route navigation does not re-prompt. Cleared on tab close, so the // refresh / route navigation does not re-prompt. Cleared on tab close, so the
@@ -32,11 +41,10 @@ export function AgentGallery() {
const router = useRouter(); const router = useRouter();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<string>("mine"); const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc"); const [sort, setSort] = useState<AgentSort>("time_desc");
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
const { data: publishSquares } = usePublishSquares(); const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the // Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default). // backend always returns at least one (the built-in default).
@@ -53,10 +61,9 @@ export function AgentGallery() {
// frontend's desensitize map, so typing the displayed abbreviation (e.g. // frontend's desensitize map, so typing the displayed abbreviation (e.g.
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the // "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
// filters change, every tab returns to its first page. // filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => { useEffect(() => {
setPages({}); setPages({});
}, [search, tagKey, sort, publisher?.id]); }, [search, sort, publisher?.id, statusFilter]);
// If the active square tab disappears (admin removed that square), fall back // If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab. // to "我的" so the gallery never points at a dead tab.
@@ -108,7 +115,6 @@ export function AgentGallery() {
} = useAgentsPaginated({ } = useAgentsPaginated({
scope: mainScope, scope: mainScope,
search, search,
tagIds,
sort, sort,
page: mainPage, page: mainPage,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -126,7 +132,6 @@ export function AgentGallery() {
error: privateError, error: privateError,
} = usePrivateAgentsPaginated(privatePassword, { } = usePrivateAgentsPaginated(privatePassword, {
search, search,
tagIds,
sort, sort,
page: pages.private ?? 1, page: pages.private ?? 1,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -177,21 +182,22 @@ export function AgentGallery() {
const handlePublisherClick = (p: { id: string; label: string }) => const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p); setPublisher(p);
const renderGrid = (list: Agent[]) => const filterByStatus = (agent: Agent) => {
viewMode === "list" ? ( if (statusFilter === "published") return agent.published && !agent.is_private;
<div className="flex flex-col gap-2"> if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin;
{list.map((agent) => ( if (statusFilter === "private") return agent.is_private;
<AgentCard if (statusFilter === "builtin") return agent.builtin;
key={agent.id} return true;
agent={agent} };
layout="list"
onPublisherClick={handlePublisherClick} const renderGrid = (list: Agent[]) => {
/> const visibleAgents = list.filter(filterByStatus);
))} if (visibleAgents.length === 0 && list.length > 0) {
</div> return renderEmpty("当前状态筛选下没有智能体。");
) : ( }
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> return (
{list.map((agent) => ( <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleAgents.map((agent) => (
<AgentCard <AgentCard
key={agent.id} key={agent.id}
agent={agent} agent={agent}
@@ -200,6 +206,7 @@ export function AgentGallery() {
))} ))}
</div> </div>
); );
};
/** Loading / empty / (grid + pagination) for one paginated tab. */ /** Loading / empty / (grid + pagination) for one paginated tab. */
const renderListBody = (opts: { const renderListBody = (opts: {
@@ -264,10 +271,8 @@ export function AgentGallery() {
}; };
const renderEmpty = (description: string, showNewButton = false) => ( const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center"> <div className="flex h-72 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-blue-200 bg-white/70 text-center shadow-[0_12px_36px_rgba(37,99,235,0.05)] dark:border-blue-900 dark:bg-slate-900/70">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]"> <AgentBrandIcon size="lg" />
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div>
<div> <div>
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p> <p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]"> <p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
@@ -276,8 +281,7 @@ export function AgentGallery() {
</div> </div>
{showNewButton && ( {showNewButton && (
<Button <Button
variant="outline" className="mt-2 rounded-xl bg-[#1f63e9] px-5 shadow-[0_8px_20px_rgba(31,99,233,0.22)] hover:bg-[#1857d4] dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
onClick={handleNewAgent} onClick={handleNewAgent}
> >
<PlusIcon className="mr-1.5 h-4 w-4" /> <PlusIcon className="mr-1.5 h-4 w-4" />
@@ -364,17 +368,22 @@ export function AgentGallery() {
}; };
return ( return (
<div className="flex size-full min-h-0 flex-col"> <div className="flex size-full min-h-0 flex-col overflow-hidden border-y border-r border-slate-200/80 bg-white dark:border-slate-800 dark:bg-slate-950">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex shrink-0 items-start justify-between px-[38px] pt-[45px] pb-[38px]">
<div> <div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1> <h1 className="text-[28px] leading-none font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
<p className="text-muted-foreground mt-0.5 text-sm"> {t.agents.title}
{t.agents.description} </h1>
<p className="mt-4 text-[15px] text-slate-500 dark:text-slate-400">
创建并管理专属的 AI 智能体,助力更高效的工作与决策。
</p> </p>
</div> </div>
<Button onClick={handleNewAgent}> <Button
<PlusIcon className="mr-1.5 h-4 w-4" /> onClick={handleNewAgent}
className="h-12 w-[154px] rounded-lg bg-gradient-to-r from-[#2467ea] to-[#0f54ed] px-0 text-[15px] font-medium shadow-[0_9px_22px_rgba(31,99,233,0.22)] hover:brightness-105"
>
<PlusIcon className="mr-2 h-[18px] w-[18px]" />
{t.agents.newAgent} {t.agents.newAgent}
</Button> </Button>
</div> </div>
@@ -384,43 +393,99 @@ export function AgentGallery() {
value={activeTab} value={activeTab}
onValueChange={(v) => { onValueChange={(v) => {
setActiveTab(v); setActiveTab(v);
// Each tab starts from a clean slate: switching tabs clears the // Each tab starts from a clean slate instead of carrying filters over.
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
setSearch(""); setSearch("");
setTagIds([]);
setSort("time_desc"); setSort("time_desc");
setStatusFilter("all");
setPublisher(null); setPublisher(null);
}} }}
className="flex min-h-0 flex-1 flex-col overflow-hidden" className="flex min-h-0 flex-1 flex-col overflow-hidden"
> >
<div className="mx-6 mt-4 flex flex-col gap-3"> <div className="shrink-0 px-[38px]">
<div className="flex items-center justify-between gap-3"> <div className="flex min-h-14 flex-col gap-3 border-b border-slate-200/90 lg:flex-row lg:items-end lg:justify-between dark:border-slate-800">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start"> <TabsList variant="line" className="h-auto w-auto max-w-full flex-wrap justify-start self-start rounded-none border-0 bg-transparent p-0">
<TabsTrigger value="mine">我的</TabsTrigger> <TabsTrigger
value="mine"
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-3 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
>
我的
</TabsTrigger>
{squareTabs.map((sq) => ( {squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}> <TabsTrigger
key={sq.id}
value={sq.id}
title={sq.description || sq.name}
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
>
{sq.name} {sq.name}
</TabsTrigger> </TabsTrigger>
))} ))}
{privateFeatureAvailable && ( {privateFeatureAvailable && (
<TabsTrigger value="private"> <TabsTrigger
value="private"
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
>
<LockIcon className="mr-1 h-3.5 w-3.5" /> <LockIcon className="mr-1 h-3.5 w-3.5" />
私密广场 私密广场
</TabsTrigger> </TabsTrigger>
)} )}
{isAdmin && ( {isAdmin && (
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)"> <TabsTrigger
value="builtin"
title="内置/功能型智能体(仅管理员可见)"
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
>
<BotIcon className="mr-1 h-3.5 w-3.5" /> <BotIcon className="mr-1 h-3.5 w-3.5" />
内置智能体 内置智能体
</TabsTrigger> </TabsTrigger>
)} )}
</TabsList> </TabsList>
<div className="flex items-center gap-3"> {showSearchBar && (
<span className="text-muted-foreground text-sm whitespace-nowrap"> <div className="flex flex-wrap items-center justify-end gap-5 pb-2.5">
共 {activeTotal} 个 <div className="relative w-[350px] max-w-full">
</span> <SearchIcon className="pointer-events-none absolute top-1/2 left-3.5 size-[18px] -translate-y-1/2 text-slate-500" />
<ViewModeToggle value={viewMode} onChange={setViewMode} /> <Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索智能体名称或描述"
className="h-12 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-blue-400 focus-visible:ring-blue-100 dark:border-slate-700 dark:bg-slate-900"
/>
{(activeTab === "private" ? isPrivateFetching : mainFetching) && (
<Loader2 className="absolute top-1/2 right-3.5 size-4 -translate-y-1/2 animate-spin text-blue-500" />
)}
</div>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as AgentStatusFilter)}
>
<SelectTrigger className="h-12 w-[156px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="published">已发布</SelectItem>
<SelectItem value="draft">未发布</SelectItem>
<SelectItem value="private">私密</SelectItem>
<SelectItem value="builtin">内置</SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-12 w-[156px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700"
title="排序"
>
<ArrowDownUpIcon className="mr-1 size-4 text-slate-500" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">更新时间</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
{activeTab === "private" && privatePassword && ( {activeTab === "private" && privatePassword && (
<Button <Button
variant="outline" variant="outline"
@@ -433,47 +498,15 @@ export function AgentGallery() {
</Button> </Button>
)} )}
</div> </div>
</div>
{showSearchBar && (
<TagSearchBar
// Remount per tab so the internal keyword input / 展开 state also
// resets when the parent-owned filters are cleared on tab switch.
key={activeTab}
targetType="agent"
searching={
activeTab === "private" ? isPrivateFetching : mainFetching
}
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
)} )}
</div>
{publisher && ( {publisher && (
<div className="flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span> <span className="text-muted-foreground text-xs">筛选发布者:</span>
<button <button
type="button" type="button"
onClick={() => setPublisher(null)} onClick={() => setPublisher(null)}
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-300"
title="点击清除发布者筛选" title="点击清除发布者筛选"
> >
{publisher.label} {publisher.label}
@@ -481,13 +514,16 @@ export function AgentGallery() {
</button> </button>
</div> </div>
)} )}
<div className="py-5 text-[15px] text-slate-600 dark:text-slate-300">
共 {activeTotal} 个智能体
</div>
</div> </div>
{squareTabs.map((sq) => ( {squareTabs.map((sq) => (
<TabsContent <TabsContent
key={sq.id} key={sq.id}
value={sq.id} value={sq.id}
className="flex-1 overflow-y-auto p-6 mt-0" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
> >
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
@@ -500,7 +536,7 @@ export function AgentGallery() {
</TabsContent> </TabsContent>
))} ))}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="mine" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
total: mainTotal, total: mainTotal,
@@ -513,7 +549,7 @@ export function AgentGallery() {
</TabsContent> </TabsContent>
{isAdmin && ( {isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="builtin" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
total: mainTotal, total: mainTotal,
@@ -528,7 +564,7 @@ export function AgentGallery() {
{privateFeatureAvailable && ( {privateFeatureAvailable && (
<TabsContent <TabsContent
value="private" value="private"
className="flex-1 overflow-y-auto p-6 mt-0" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
> >
{renderPrivateTab()} {renderPrivateTab()}
</TabsContent> </TabsContent>
@@ -0,0 +1,204 @@
"use client";
import {
BookOpenCheck,
Brain,
CalendarClock,
MessageSquare,
MessageSquarePlus,
PanelLeftClose,
PenLine,
Settings,
Sparkles,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { MouseEvent } from "react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
import { AgentBrandIcon } from "./agents/agent-brand-icon";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
type NavItem = {
label: string;
href: string;
icon: typeof MessageSquare;
match: string;
};
const NAV_ITEMS: NavItem[] = [
{ label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH },
{ label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" },
{ label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" },
{ label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" },
{ label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" },
{ label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" },
{
label: "多智能体规划",
href: "/page/workspace/roundtable/planning",
icon: UsersRound,
match: "/page/workspace/roundtable",
},
{
label: "定时任务",
href: "/page/workspace/scheduled/tasks",
icon: CalendarClock,
match: "/page/workspace/scheduled",
},
{ label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" },
] as const;
export function CmChatSidebar({
onCollapse,
collapsed = false,
}: {
onCollapse?: () => void;
collapsed?: boolean;
}) {
const pathname = usePathname();
const navigate = useNavigate();
const handleNewChat = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
};
if (collapsed) {
return (
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
<button
type="button"
onClick={onCollapse}
aria-label="展开侧边栏"
title="展开侧边栏"
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:hover:bg-slate-900"
>
<img
src="/cm-assistant-logo.png"
alt="CM 助手"
className="size-10 object-contain"
/>
</button>
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
<nav className="flex shrink-0 flex-col items-center gap-1 px-2 py-3" aria-label="工作台导航">
{NAV_ITEMS.map((item) => {
const active = item.href === NEW_CHAT_PATH
? pathname === NEW_CHAT_PATH
: item.href === "/page/workspace/chats"
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
: pathname.startsWith(item.match);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
aria-label={item.label}
title={item.label}
className={cn(
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
active
? "bg-[#edf4ff] text-[#165ee8] dark:bg-blue-500/15 dark:text-blue-300"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[19px]" strokeWidth={1.8} />
)}
<span className="sr-only">{item.label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
<Link
href="/page/workspace/settings"
aria-label="设置和更多"
title="设置和更多"
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
>
<Settings className="size-[19px]" strokeWidth={1.8} />
</Link>
</div>
</aside>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
<div className="flex shrink-0 items-center gap-2 px-6 pt-4 pb-3">
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-3.5 outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">
<img
src="/cm-assistant-logo.png"
alt="CM 助手"
className="size-10 shrink-0 object-contain"
/>
<span className="truncate text-[20px] font-semibold tracking-[-0.025em]">CM 助手</span>
</Link>
<button
type="button"
onClick={onCollapse}
aria-label="收起侧边栏"
title="收起侧边栏"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:hover:bg-slate-900 dark:hover:text-white"
>
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
</button>
</div>
<nav className="shrink-0 px-4 pt-1" aria-label="工作台导航">
<div className="space-y-1">
{NAV_ITEMS.map((item) => {
const active = item.href === NEW_CHAT_PATH
? pathname === NEW_CHAT_PATH
: item.href === "/page/workspace/chats"
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
: pathname.startsWith(item.match);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
className={cn(
"flex h-11 items-center gap-3 rounded-xl px-4 text-[14px] font-medium transition-colors",
active
? "bg-[#edf4ff] text-[#165ee8] dark:bg-blue-500/15 dark:text-blue-300"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
)}
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-1">
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
</div>
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
<WorkspaceNavMenu />
</div>
</div>
);
}
@@ -11,38 +11,15 @@ export function CompactWelcome({ className }: { className?: string }) {
return ( return (
<div <div
className={cn( className={cn(
"mx-auto flex w-full flex-col items-center justify-center text-center select-none", "mx-auto flex w-full flex-col items-center justify-center text-center",
className, className,
)} )}
> >
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */} <h1 className="flex items-center justify-center text-[34px] font-semibold tracking-[-0.035em] text-slate-950 dark:text-slate-50">
<div 你好,欢迎使用 CM 助手
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
<div
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
style={{
backgroundImage:
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
}}
>
<span className="text-2xl font-black tracking-wide text-white italic">
CM
</span>
</div>
</div>
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
欢迎使用CM助手
</h1> </h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed"> <p className="mx-auto mt-2.5 max-w-xl text-[15px] leading-6 text-slate-500 dark:text-slate-400">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 我可以帮你写作、问答、分析和规划各类内容,让工作更高效
</p> </p>
</div> </div>
); );
@@ -46,10 +46,12 @@ export function CustomPromptBar({
onInsert, onInsert,
className, className,
currentAgentId, currentAgentId,
compact = false,
}: { }: {
onInsert: (content: string) => void; onInsert: (content: string) => void;
className?: string; className?: string;
currentAgentId?: string | null; currentAgentId?: string | null;
compact?: boolean;
}) { }) {
const allPrompts = useCustomPrompts(); const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter( const prompts = allPrompts.filter(
@@ -81,7 +83,13 @@ export function CustomPromptBar({
return ( return (
<> <>
<div className={cn("flex min-w-0 items-center gap-1", className)}> <div
className={cn(
"flex min-w-0 items-center gap-1",
compact && "min-h-9 justify-end",
className,
)}
>
{prompts.length > 0 && ( {prompts.length > 0 && (
<> <>
{overflowing && ( {overflowing && (
@@ -111,6 +119,7 @@ export function CustomPromptBar({
title={prompt.content} title={prompt.content}
className={cn( className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors", "border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50/60 hover:text-blue-700",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700", "dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)} )}
> >
@@ -139,7 +148,11 @@ export function CustomPromptBar({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full" className={cn(
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
compact &&
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 dark:border-slate-700 dark:bg-slate-900",
)}
title="管理常用提示词" title="管理常用提示词"
aria-label="管理常用提示词" aria-label="管理常用提示词"
onClick={() => setManageOpen(true)} onClick={() => setManageOpen(true)}
@@ -2,14 +2,18 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BarChart3Icon,
BrainCircuitIcon, BrainCircuitIcon,
BookMarkedIcon, BookMarkedIcon,
CheckIcon, CheckIcon,
ClipboardCheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
LightbulbIcon, LightbulbIcon,
MessageCircleQuestionIcon,
PaperclipIcon, PaperclipIcon,
PlusIcon, PlusIcon,
RefreshCwIcon,
ShrinkIcon, ShrinkIcon,
SparklesIcon, SparklesIcon,
RocketIcon, RocketIcon,
@@ -104,6 +108,57 @@ import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra"; type InputMode = "flash" | "thinking" | "pro" | "ultra";
const COMPACT_QUICK_STARTS = [
{
id: "report",
title: "撰写报告",
content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。",
icon: FileTextIcon,
},
{
id: "knowledge",
title: "知识问答",
content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。",
icon: MessageCircleQuestionIcon,
},
{
id: "planning",
title: "任务规划",
content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。",
icon: ClipboardCheckIcon,
},
{
id: "analysis",
title: "数据分析",
content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。",
icon: BarChart3Icon,
},
{
id: "solution",
title: "方案策划",
content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。",
icon: LightbulbIcon,
},
{
id: "summary",
title: "内容总结",
content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。",
icon: BookMarkedIcon,
},
{
id: "research",
title: "调研提纲",
content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。",
icon: GraduationCapIcon,
},
{
id: "polish",
title: "文案润色",
content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。",
icon: SparklesIcon,
},
] as const;
function getResolvedMode( function getResolvedMode(
mode: InputMode | undefined, mode: InputMode | undefined,
@@ -211,6 +266,7 @@ export function InputBox({
const wasStreamingRef = useRef(false); const wasStreamingRef = useRef(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>( const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null, null,
); );
@@ -221,6 +277,29 @@ export function InputBox({
!hideAdminRecommendedQuestions && !hideAdminRecommendedQuestions &&
!userPreferences.hide_admin_prompt_prefix && !userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
const compactQuickStarts = useMemo(() => {
if (!hideAdminRecommendedQuestions && recommendedQuestions.length > 0) {
return recommendedQuestions.map((configured, index) => {
const fallback =
COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!;
return {
...fallback,
id: configured.id,
title: desensitize(configured.title) || `提示词 ${index + 1}`,
content: configured.content || fallback.content,
};
});
}
return [...COMPACT_QUICK_STARTS];
}, [hideAdminRecommendedQuestions, recommendedQuestions]);
const orderedCompactQuickStarts = useMemo(() => {
if (compactQuickStarts.length < 2) return compactQuickStarts;
const offset = compactPromptOrder % compactQuickStarts.length;
return [
...compactQuickStarts.slice(offset),
...compactQuickStarts.slice(0, offset),
];
}, [compactPromptOrder, compactQuickStarts]);
useEffect(() => { useEffect(() => {
if (models.length === 0) { if (models.length === 0) {
@@ -256,6 +335,7 @@ export function InputBox({
() => selectedModel?.supports_thinking ?? false, () => selectedModel?.supports_thinking ?? false,
[selectedModel], [selectedModel],
); );
const effectiveMode = getResolvedMode(context.mode, supportThinking);
const supportReasoningEffort = useMemo( const supportReasoningEffort = useMemo(
() => selectedModel?.supports_reasoning_effort ?? false, () => selectedModel?.supports_reasoning_effort ?? false,
@@ -529,14 +609,68 @@ export function InputBox({
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]); }, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return ( return (
<div ref={promptRootRef} className="relative flex flex-col gap-6"> <div
ref={promptRootRef}
className={cn(
"relative flex flex-col",
compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6",
)}
>
{extraHeader && ( {extraHeader && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
{extraHeader} {extraHeader}
</div> </div>
)} )}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( {isNewThread && searchParams.get("mode") !== "skill" && compact && (
<section className="px-2" aria-labelledby="recommended-prompts-title">
<div className="mb-3 flex items-center justify-center gap-1.5">
<h2
id="recommended-prompts-title"
className="text-[14px] font-normal text-slate-400 dark:text-slate-500"
>
你可以这样问我
</h2>
<button
type="button"
data-prompt-order-refresh="true"
title="调整提示词顺序"
aria-label="调整提示词顺序"
onClick={() => setCompactPromptOrder((current) => current + 1)}
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-blue-600 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-blue-500/15 dark:hover:bg-slate-800 dark:hover:text-blue-300"
>
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
</button>
</div>
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
{orderedCompactQuickStarts.map((item) => {
return (
<button
key={item.id}
type="button"
data-quick-prompt={item.id}
title={item.content}
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-blue-500/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800"
onClick={() => {
textInput.setInput(item.content);
requestAnimationFrame(() =>
document
.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)
?.focus(),
);
}}
>
<span className="max-w-full whitespace-normal">{item.title}</span>
</button>
);
})}
</div>
</section>
)}
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]"> <div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => ( {recommendedQuestions.map((q) => (
<button <button
@@ -589,7 +723,8 @@ export function InputBox({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{isNewThread && ( {isNewThread && (
<CustomPromptBar <CustomPromptBar
className="px-1" compact={compact}
className={cn(compact ? "px-2" : "px-1")}
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null} currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
onInsert={(content) => { onInsert={(content) => {
const current = textInput.value?.trim() ?? ""; const current = textInput.value?.trim() ?? "";
@@ -613,7 +748,7 @@ export function InputBox({
: "*:data-[slot='input-group']:rounded-3xl", : "*:data-[slot='input-group']:rounded-3xl",
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边) // 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
compact && isNewThread compact && isNewThread
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800" ? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(37,99,235,0.6),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-blue-300 focus-within:ring-4 focus-within:ring-blue-500/10 dark:border-slate-700 dark:bg-slate-800"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边 : // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影 // 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread isNewThread
@@ -637,7 +772,8 @@ export function InputBox({
className={cn( className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60", "size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
isNewThread && "min-h-[110px]!", isNewThread && "min-h-[110px]!",
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]", compact &&
"min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
)} )}
disabled={disabled} disabled={disabled}
placeholder={t.inputBox.placeholder} placeholder={t.inputBox.placeholder}
@@ -648,7 +784,7 @@ export function InputBox({
<PromptInputFooter <PromptInputFooter
className={cn( className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5", "flex items-center border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3", compact && "gap-2 px-6 pt-2 pb-5",
)} )}
> >
<PromptInputTools <PromptInputTools
@@ -784,12 +920,7 @@ export function InputBox({
<PromptInputActionMenu> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
context.mode === "flash" || effectiveMode
context.mode === "thinking" ||
context.mode === "pro" ||
context.mode === "ultra"
? context.mode
: "flash"
} }
> >
<PromptInputActionMenuTrigger <PromptInputActionMenuTrigger
@@ -797,38 +928,38 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium", compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact && compact &&
(context.mode === "thinking" (effectiveMode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200" ? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
: context.mode === "pro" : effectiveMode === "pro"
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200" ? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: context.mode === "ultra" : effectiveMode === "ultra"
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200" ? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"), : "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
)} )}
> >
<div> <div>
{context.mode === "flash" && <ZapIcon className="size-3" />} {effectiveMode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && ( {effectiveMode === "thinking" && (
<LightbulbIcon className="size-3" /> <LightbulbIcon className="size-3" />
)} )}
{context.mode === "pro" && ( {effectiveMode === "pro" && (
<GraduationCapIcon className="size-3" /> <GraduationCapIcon className="size-3" />
)} )}
{context.mode === "ultra" && ( {effectiveMode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" /> <RocketIcon className="size-3 text-[#dabb5e]" />
)} )}
</div> </div>
<div <div
className={cn( className={cn(
"text-xs font-normal", "text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "", effectiveMode === "ultra" ? "golden-text" : "",
)} )}
> >
{(context.mode === "flash" && t.inputBox.flashMode) || {(effectiveMode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" && (effectiveMode === "thinking" &&
t.inputBox.reasoningMode) || t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) || (effectiveMode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)} (effectiveMode === "ultra" && t.inputBox.ultraMode)}
</div> </div>
</PromptInputActionMenuTrigger> </PromptInputActionMenuTrigger>
</ModeHoverGuide> </ModeHoverGuide>
@@ -1125,6 +1256,8 @@ export function InputBox({
> >
<ModelSelectorTrigger asChild> <ModelSelectorTrigger asChild>
<PromptInputButton <PromptInputButton
disabled={models.length === 0}
aria-label="模型选择"
className={cn( className={cn(
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && compact &&
@@ -1138,7 +1271,7 @@ export function InputBox({
compact && "text-[11px]", compact && "text-[11px]",
)} )}
> >
{selectedModel?.display_name} {selectedModel?.display_name || "默认模型"}
</ModelSelectorName> </ModelSelectorName>
</div> </div>
</PromptInputButton> </PromptInputButton>
@@ -1172,7 +1305,7 @@ export function InputBox({
className={cn( className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none", "rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact && compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted", "size-10 rounded-full border-0 bg-blue-600 text-white shadow-[0_8px_18px_-10px_rgba(37,99,235,0.9)] hover:bg-blue-700 disabled:bg-muted disabled:text-muted-foreground/40 dark:border-0 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:disabled:bg-muted",
)} )}
disabled={disabled} disabled={disabled}
variant="outline" variant="outline"
@@ -65,7 +65,10 @@ import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources"; import {
isWeKnoraSourcePreviewUrl,
SourceCard,
} from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
@@ -651,6 +654,30 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
sourceLabel = sourceLabel ?? item.url; sourceLabel = sourceLabel ?? item.url;
} }
} }
// MCP retrieval tools without a configured display adapter fall back to this
// generic result card. Preserve the normal web-search UI, but route our
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
// deliberate “查看原文” action rather than rendering the internal route text.
if (isWeKnoraSourcePreviewUrl(item.url)) {
const weknoraSource: RuntimeDisplaySource = {
id: item.id ?? item.uuid ?? item.docId,
title: item.title ?? "检索结果",
type: item.source ?? "document",
snippet: item.snippet ?? item.content ?? "",
content,
source: item.source,
author: item.author,
time: date,
url: item.url,
index: 0,
skillName: "weknora-knowledge-search",
mode: "list",
raw: { ...item },
};
return <SourceCard source={weknoraSource} />;
}
return ( return (
<> <>
<button <button
@@ -26,6 +26,7 @@ import {
TrendingUpIcon, TrendingUpIcon,
Upload, Upload,
UserIcon, UserIcon,
WorkflowIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -50,13 +51,6 @@ import {
EmptyTitle, EmptyTitle,
} from "@/components/ui/empty"; } from "@/components/ui/empty";
import {
Item,
ItemActions,
ItemTitle,
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -744,7 +738,7 @@ export function SkillRow({
const badgeGroup = ( const badgeGroup = (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}> <span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)} {skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
</span> </span>
{skill.name_zh?.trim() && ( {skill.name_zh?.trim() && (
@@ -907,7 +901,7 @@ export function SkillRow({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
onClick={() => setTagPickerOpen(true)} onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags} title={t.adminTags.manageTags}
> >
@@ -918,7 +912,7 @@ export function SkillRow({
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.featured && "text-amber-600 hover:text-amber-700", skill.featured && "text-amber-600 hover:text-amber-700",
)} )}
disabled={setFeatured.isPending} disabled={setFeatured.isPending}
@@ -939,7 +933,7 @@ export function SkillRow({
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.pinned && "text-emerald-600 hover:text-emerald-700", skill.pinned && "text-emerald-600 hover:text-emerald-700",
)} )}
disabled={setPinned.isPending} disabled={setPinned.isPending}
@@ -960,7 +954,7 @@ export function SkillRow({
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.always_on && "text-amber-600 hover:text-amber-700", skill.always_on && "text-amber-600 hover:text-amber-700",
)} )}
disabled={setAlwaysOn.isPending} disabled={setAlwaysOn.isPending}
@@ -999,7 +993,7 @@ export function SkillRow({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
disabled={duplicateSkillMutation.isPending} disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate} onClick={handleOpenDuplicate}
title="复制为新技能" title="复制为新技能"
@@ -1010,7 +1004,7 @@ export function SkillRow({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={() => setConfirmDeleteOpen(true)} onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction} title={t.settings.skills.deleteAction}
> >
@@ -1020,38 +1014,121 @@ export function SkillRow({
</> </>
); );
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
? {
Icon: GlobeIcon,
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
}
: skill.name.includes("doc") || skill.name.includes("file")
? {
Icon: FileArchive,
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
}
: skill.source === "agent"
? {
Icon: WorkflowIcon,
className: "bg-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]",
}
: {
Icon: Upload,
className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]",
};
const SkillIcon = iconStyle.Icon;
const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at);
const actionArea = (
<div className="flex flex-wrap items-center justify-end gap-1" onClick={(event) => event.stopPropagation()}>
{actionButtons}
</div>
);
return ( return (
<> <>
{isCard ? ( {isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"> <div className="flex h-full min-w-0 flex-col rounded-xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<div <div
className="min-w-0 flex-1 cursor-pointer" className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
> >
<div className="flex items-start gap-3.5">
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
<SkillIcon className="size-6" />
</div>
<div className="min-w-0 flex-1">
{badgeGroup} {badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm"> <p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")} {desensitize(skill.description ?? "")}
</p> </p>
{metaNodes}
</div> </div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]"> </div>
{actionButtons} <div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
</div>
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
</div>
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
{actionArea}
</div> </div>
</div> </div>
) : ( ) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline"> <div className="group/skill-card w-full rounded-xl border border-slate-200 bg-white px-5 py-4 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<ItemContent <div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(150px,0.72fr)_minmax(100px,0.42fr)_minmax(132px,0.58fr)_auto] xl:items-center xl:gap-x-6">
className="cursor-pointer" <div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
onClick={() => setDetailOpen(true)} <div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
> <SkillIcon className="size-7" />
<ItemTitle>{badgeGroup}</ItemTitle> </div>
<ItemDescription className="line-clamp-4"> <div className="min-w-0 flex-1 pt-0.5">
{badgeGroup}
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")} {desensitize(skill.description ?? "")}
</ItemDescription> </p>
{metaNodes} {!isBuiltin && skill.owner_username && (
</ItemContent> <button
<ItemActions>{actionButtons}</ItemActions> type="button"
</Item> disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(event) => {
event.stopPropagation();
if (!skill.owner_user_id || !onPublisherClick) return;
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
}}
className={cn(
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-blue-600 hover:underline",
)}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
</div>
</div>
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
{(skill.tags?.length ?? 0) > 0 ? (
<TagChipList tags={skill.tags!} />
) : (
<span className="text-xs text-slate-400">未设置标签</span>
)}
</div>
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
</div>
<div className="min-w-0">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
</div>
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
{actionArea}
</div>
</div>
</div>
)} )}
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}> <Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent> <DialogContent>
@@ -53,6 +53,7 @@ export function SourceCard({
referenceTarget?: boolean; referenceTarget?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showOriginal, setShowOriginal] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null); const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false); const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null); const [contextError, setContextError] = useState<string | null>(null);
@@ -81,7 +82,7 @@ export function SourceCard({
const previewTarget = parseWeKnoraPreviewUrl(source.url); const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => { useEffect(() => {
if (!open || !previewTarget) { if (!open || !showOriginal || !previewTarget) {
return; return;
} }
let cancelled = false; let cancelled = false;
@@ -115,7 +116,7 @@ export function SourceCard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]); }, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
return ( return (
<> <>
@@ -128,7 +129,10 @@ export function SourceCard({
highlighted && highlighted &&
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]", "border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
)} )}
onClick={() => setOpen(true)} onClick={() => {
setShowOriginal(false);
setOpen(true);
}}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{showReferenceNumber && ( {showReferenceNumber && (
@@ -168,7 +172,15 @@ export function SourceCard({
)} )}
</div> </div>
</button> </button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) {
setShowOriginal(false);
}
}}
>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8"> <div className="flex items-start justify-between gap-3 pr-8">
@@ -205,7 +217,27 @@ export function SourceCard({
)} )}
</div> </div>
</DialogHeader> </DialogHeader>
{previewTarget ? ( {previewTarget && (
<div className="border-b pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
{showOriginal
? "正在查看命中文段及其前后上下文"
: "当前为检索命中的片段,可继续查看原文上下文"}
</span>
<Button
type="button"
size="sm"
variant={showOriginal ? "outline" : "default"}
onClick={() => setShowOriginal((current) => !current)}
>
<FileTextIcon className="size-3.5" />
{showOriginal ? "返回检索片段" : "查看原文"}
</Button>
</div>
</div>
)}
{previewTarget && showOriginal ? (
<WeKnoraSourceContextPanel <WeKnoraSourceContextPanel
context={context} context={context}
loading={contextLoading} loading={contextLoading}
@@ -249,12 +281,27 @@ type WeKnoraSourceContext = {
}>; }>;
}; };
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
return parseWeKnoraPreviewUrl(url) !== null;
}
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null { function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) { const normalizedUrl = url
?.trim()
.replace(/^[\u200B-\u200D\uFEFF]+/, "");
const marker = "/weknora-source-preview?";
const start = normalizedUrl?.indexOf(marker) ?? -1;
if (start < 0) {
return null; return null;
} }
const queryIndex = url.indexOf("?"); // Some MCP tool results wrap the relative pointer in formatting characters
const params = new URLSearchParams(url.slice(queryIndex + 1)); // or append a newline. Keep accepting that safe internal path, but never
// treat a normal external URL as an original-document preview.
const previewUrl = normalizedUrl!
.slice(start)
.split(/[\r\n\s]/, 1)[0];
const queryIndex = previewUrl.indexOf("?");
const params = new URLSearchParams(previewUrl.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim(); const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim(); const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null; return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
@@ -1,6 +1,6 @@
"use client"; "use client";
import { User } from "lucide-react"; import { UserRound } from "lucide-react";
import { useLiveAccount } from "@/core/auth/use-live-account"; import { useLiveAccount } from "@/core/auth/use-live-account";
import { useMyPosition } from "@/core/positions"; import { useMyPosition } from "@/core/positions";
@@ -15,7 +15,13 @@ import { cn } from "@/lib/utils";
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏, * 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
* 而非 Header 的白字贴 banner 方案。 * 而非 Header 的白字贴 banner 方案。
*/ */
export function UserIdentityBadge({ className }: { className?: string }) { export function UserIdentityBadge({
className,
variant = "plain",
}: {
className?: string;
variant?: "plain" | "topbar";
}) {
// userInfo belongs to external-app route parameters and can legitimately // userInfo belongs to external-app route parameters and can legitimately
// outlive a login switch. The visible account name must instead match the // outlive a login switch. The visible account name must instead match the
// settings page's current auth identity. // settings page's current auth identity.
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
return ( return (
<div <div
className={cn("flex shrink-0 items-center gap-2", className)} data-workspace-user={variant}
className={cn(
"flex shrink-0 items-center gap-2",
variant === "topbar" &&
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
className,
)}
title={positionName ? `${displayName}·${positionName}` : displayName} title={positionName ? `${displayName}·${positionName}` : displayName}
> >
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"> <div
<User className="size-3.5" /> className={cn(
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
variant === "topbar" &&
"size-8 bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-[0_4px_12px_-6px_rgba(37,99,235,0.8)] ring-1 ring-blue-500/10 dark:from-blue-400 dark:to-indigo-500",
)}
>
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
{variant === "topbar" && (
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
)}
</div> </div>
<div className="hidden min-w-0 text-left sm:block"> <div
className={cn(
"hidden min-w-0 text-left sm:block",
variant === "topbar" && "max-w-[165px]",
)}
>
<p className="text-foreground truncate text-sm font-medium leading-tight"> <p className="text-foreground truncate text-sm font-medium leading-tight">
{displayName} {displayName}
</p> </p>
@@ -14,6 +14,8 @@ import {
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
export function WorkspaceContainer({ export function WorkspaceContainer({
className, className,
@@ -93,9 +95,15 @@ export function WorkspaceHeader({
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>
{rightActions ? ( <div className="flex items-center gap-2 px-4">
<div className="flex items-center gap-2 px-4">{rightActions}</div> {rightActions}
) : null} <ThemeSwitcher tone="content" />
<div
aria-hidden="true"
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
/>
<UserIdentityBadge variant="topbar" />
</div>
</header> </header>
); );
} }
@@ -125,5 +133,6 @@ function nameOfSegment(
if (!segment) return t.common.home; if (!segment) return t.common.home;
if (segment === "workspace") return t.breadcrumb.workspace; if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats; if (segment === "chats") return t.breadcrumb.chats;
if (segment === "agents") return t.agents.title;
return segment[0]?.toUpperCase() + segment.slice(1); return segment[0]?.toUpperCase() + segment.slice(1);
} }
@@ -2,117 +2,43 @@
import { import {
Sidebar, Sidebar,
SidebarHeader,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar"; import { CmChatSidebar } from "./cm-chat-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({ export function WorkspaceSidebar({
compactChrome = false,
className, className,
compactChrome: _compactChrome,
...props ...props
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) { }: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
const { open: isSidebarOpen } = useSidebar(); const { open: isSidebarOpen, toggleSidebar } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return ( return (
<div <div
aria-hidden={!isSidebarOpen} aria-hidden={false}
className={cn( className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out", "h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none", isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
)} )}
> >
<Sidebar <Sidebar
variant="sidebar" variant="sidebar"
collapsible="none" collapsible="none"
className={cn( className={cn(
"h-full w-(--sidebar-width) border-r border-border", "h-full border-r border-border",
useWhiteSidebar && isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white", "[&_[data-slot=sidebar-container]]:bg-white [&_[data-slot=sidebar-inner]]:bg-white",
className, className,
)} )}
{...props} {...props}
> >
<SidebarContent> <SidebarContent>
{isGeneralQa ? ( <CmChatSidebar onCollapse={toggleSidebar} collapsed={!isSidebarOpen} />
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent> </SidebarContent>
<SidebarRail />
</Sidebar> </Sidebar>
</div> </div>
); );
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
} }
+11 -1
View File
@@ -1,5 +1,15 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery"; import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
export default function AgentsPage() { export default function AgentsPage() {
return <AgentGallery />; return (
<WorkspaceContainer className="bg-white dark:bg-slate-950">
<WorkspaceBody>
<AgentGallery />
</WorkspaceBody>
</WorkspaceContainer>
);
} }
+21 -31
View File
@@ -35,7 +35,6 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge"; import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome"; import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -51,7 +50,6 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
@@ -103,8 +101,6 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar(); useSidebar();
const { t } = useI18n(); const { t } = useI18n();
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
const [effectiveLayoutMode] = useLayoutMode();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -444,20 +440,8 @@ export default function ChatPage() {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined; : undefined;
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画; // 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 —— const compactChrome = !isChatsIframeWorkspacePath(pathname);
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = effectiveLayoutMode;
const chromeByPreference =
layoutMode === "full"
? true
: layoutMode === "compact"
? false
: readSidebarEnabled();
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; const compactLanding = isLandingState && compactChrome;
return ( return (
@@ -470,16 +454,12 @@ export default function ChatPage() {
compactChrome && "workspace-chat-bg", compactChrome && "workspace-chat-bg",
)} )}
> >
<header {(!isLandingState || compactChrome) && (
className={cn( <header className="bg-background border-border/80 absolute top-0 right-0 left-0 z-30 flex h-14 shrink-0 items-center border-b px-5">
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
{!isLandingState && (
<ThreadTitle threadId={threadId} thread={thread} /> <ThreadTitle threadId={threadId} thread={thread} />
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} {/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
@@ -507,7 +487,16 @@ export default function ChatPage() {
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */} 与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角, {/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */} 与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
{!compactChrome && ( {compactChrome ? (
<>
<ThemeSwitcher tone="content" />
<div
className="bg-border/70 mx-1 h-6 w-px shrink-0"
aria-hidden
/>
<UserIdentityBadge variant="topbar" />
</>
) : (
<> <>
<CompactShortcutButtons tone="content" /> <CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
@@ -521,6 +510,7 @@ export default function ChatPage() {
)} )}
</div> </div>
</header> </header>
)}
<main className="flex min-h-0 max-w-full grow flex-col"> <main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
{/* {/*
@@ -556,7 +546,7 @@ export default function ChatPage() {
"pointer-events-none absolute z-30 flex justify-center px-4", "pointer-events-none absolute z-30 flex justify-center px-4",
// 紧凑落地态:整块垂直+水平居中于对话区 // 紧凑落地态:整块垂直+水平居中于对话区
compactLanding compactLanding
? "inset-0 items-center" ? "inset-0 items-start overflow-y-auto pt-[clamp(82px,16vh,146px)] pb-12"
: "right-0 bottom-0 left-0", : "right-0 bottom-0 left-0",
)} )}
> >
@@ -568,7 +558,7 @@ export default function ChatPage() {
!compactLanding && !compactLanding &&
"-translate-y-[calc(50vh-96px)]", "-translate-y-[calc(50vh-96px)]",
compactLanding compactLanding
? "max-w-(--container-width-xl)" ? "max-w-[1024px]"
: isLandingState : isLandingState
? "max-w-(--container-width-md)" ? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)", : "max-w-(--container-width-lg)",
@@ -587,7 +577,7 @@ export default function ChatPage() {
</div> </div>
{isLandingState && mounted && {isLandingState && mounted &&
(compactLanding ? ( (compactLanding ? (
<CompactWelcome className="mb-6 -translate-y-4" /> <CompactWelcome className="mb-14" />
) : ( ) : (
<Welcome <Welcome
mode={settings.context.mode} mode={settings.context.mode}
@@ -595,7 +585,7 @@ export default function ChatPage() {
/> />
))} ))}
{mounted ? ( {mounted ? (
<div className="w-full -translate-y-4"> <div className={cn("w-full", !compactLanding && "-translate-y-4")}>
<InputBox <InputBox
className="w-full" className="w-full"
isNewThread={isLandingState} isNewThread={isLandingState}
-2
View File
@@ -18,7 +18,6 @@ import { ImportConversationsDialog } from "@/components/workspace/thread-share-d
import { import {
CompactShortcutButtons, CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
import { import {
WorkspaceBody, WorkspaceBody,
@@ -185,7 +184,6 @@ export default function ChatsPage({
compactChrome ? ( compactChrome ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" /> <CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
</div> </div>
) : null ) : null
+3 -1
View File
@@ -61,8 +61,10 @@ function PageLayout() {
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** // (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。 // (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent"); const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent");
const isWorkspacePage = location.pathname.startsWith("/page/workspace");
const pageSidebarAllowed = const pageSidebarAllowed =
(forceChromeForDashboardAgent || chromeByPreference) && (forceChromeForDashboardAgent || chromeByPreference) &&
!isWorkspacePage &&
!isChatsIframeWorkspacePath(location.pathname) && !isChatsIframeWorkspacePath(location.pathname) &&
!embedActive && !embedActive &&
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。 // `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
@@ -114,7 +116,7 @@ function PageLayout() {
pageSidebarCollapsed={collapsed} pageSidebarCollapsed={collapsed}
pageSidebarHidden={!pageSidebarAllowed} pageSidebarHidden={!pageSidebarAllowed}
headerHidden={!pageSidebarAllowed} headerHidden={!pageSidebarAllowed}
compactWorkspaceSidebar={pageSidebarAllowed} compactWorkspaceSidebar={pageSidebarAllowed || isWorkspacePage}
pageSidebarDark={isDark} pageSidebarDark={isDark}
/> />
} }
+2 -36
View File
@@ -11,7 +11,6 @@ import {
ThemeSwitcher, ThemeSwitcher,
} from '@/components/workspace/appearance-controls'; } from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar'; import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette'; import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context'; import { SubtasksProvider } from '@/core/tasks/context';
@@ -79,28 +78,6 @@ function isInIframe(): boolean {
} }
} }
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
);
}
function CompactWorkspaceTopActions() {
return (
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
<UserIdentityBadge />
</div>
);
}
function CompactAppearanceActions() { function CompactAppearanceActions() {
return ( return (
<> <>
@@ -137,11 +114,6 @@ export function WorkspaceLayout({
}) { }) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页 // 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。 // (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const showCompactTopActions =
headerHidden && shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden const pageSidebarWidth = pageSidebarHidden
? '0rem' ? '0rem'
: pageSidebarDark : pageSidebarDark
@@ -164,12 +136,13 @@ export function WorkspaceLayout({
{ {
'--page-sidebar-width': pageSidebarWidth, '--page-sidebar-width': pageSidebarWidth,
'--header-height': headerHidden ? '0rem' : '4rem', '--header-height': headerHidden ? '0rem' : '4rem',
'--sidebar-width': '18.5rem',
} as CSSProperties } as CSSProperties
} }
key={String(compactWorkspaceSidebar)} key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={ defaultOpen={
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar) defaultHistorySidebarOpen ?? true
} }
> >
{!workspaceSidebarHidden && ( {!workspaceSidebarHidden && (
@@ -178,14 +151,7 @@ export function WorkspaceLayout({
<SidebarInset <SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')} className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
> >
{showCompactTopActions ? (
<>
<CompactWorkspaceTopActions />
<div className="min-h-0 flex-1">{children}</div> <div className="min-h-0 flex-1">{children}</div>
</>
) : (
children
)}
</SidebarInset> </SidebarInset>
<CommandPalette /> <CommandPalette />
<Toaster position="top-center" /> <Toaster position="top-center" />
@@ -20,32 +20,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Cpu, Cpu,
FileIcon, FileIcon,
HelpCircle,
Loader2, Loader2,
Network, Network,
Paperclip,
Pause, Pause,
Play, Play,
Sparkles,
X, X,
} from "lucide-react"; } from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */ /** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true; const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter"; import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import {
Step2ClarificationCard,
Step2InterventionBar,
} from "@/roundtable-planning/components/planning/Step2Composer";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer"; import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -93,8 +86,8 @@ interface ModelOption {
} }
export interface Step2PanelProps { export interface Step2PanelProps {
// ─── State ──────────────────────────────────────────────────── session: {
step2RoundtableDialogues: Step2Dialogue[]; dialogues: Step2Dialogue[];
isInitializing: boolean; isInitializing: boolean;
isRoundTableRunning: boolean; isRoundTableRunning: boolean;
isOrchestrating: boolean; isOrchestrating: boolean;
@@ -105,63 +98,68 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>; agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null; selectedAgentId: number | null;
roles: RoleEntry[]; roles: RoleEntry[];
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)────────────── };
/** 当前筛选标识;null = 显示全部。 */ execution: {
seatFilter: SeatFilter | null; seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>; setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
// ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[]; parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[]; selectedParallelSeats: string[];
isParallelRunning: boolean; isParallelRunning: boolean;
onToggleParallelSeat: (agentId: string) => void; onToggleParallelSeat: (agentId: string) => void;
// ─── Phase 7 DAG 流程图执行态总览(§7)─────────────────────────
/** 当前编排模式;=== "dag" 且 plan 非空时,顶部显示 DAG 流程图缩略。 */
orchestrationMode: "recommend" | "chain" | "dag"; orchestrationMode: "recommend" | "chain" | "dag";
/** dag 模式的分层计划(流程图按 stage 分列渲染)。 */
orchestrationPlan: OrchestrationPlan | null; orchestrationPlan: OrchestrationPlan | null;
/** agentId(小写) → 节点状态;驱动流程图节点 active/done。 */
dagNodeStates: Record<string, ChainNodeState>; dagNodeStates: Record<string, ChainNodeState>;
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record< agentNameToRole: Record<
string, string,
{ speakerId: number; avatarType: string; displayName: string } { speakerId: number; avatarType: string; displayName: string }
>; >;
};
model: {
selectedModel: string; selectedModel: string;
availableModels: ModelOption[]; availableModels: ModelOption[];
modelsLoading: boolean; modelsLoading: boolean;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>; setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
};
artifacts: {
activeArtifactThreadId: string | null; activeArtifactThreadId: string | null;
seatStubMessages: Record<string, unknown[]>; seatStubMessages: Record<string, unknown[]>;
openArtifactFromStep: ( openArtifactFromStep: (
dialogue: Step2Dialogue, dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number], step: NonNullable<Step2Dialogue["steps"]>[number],
) => void; ) => void;
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
openArtifactPreview: (threadId: string, path: string) => void; openArtifactPreview: (threadId: string, path: string) => void;
// ─── Setters ────────────────────────────────────────────────── };
setters: {
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>; setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>; setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setAgentConfigs: React.Dispatch< setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>> React.SetStateAction<Record<number, AgentConfig>>
>; >;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>; setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ───────────────────────────────────────────────── };
actions: {
onPauseRoundtable: () => void; onPauseRoundtable: () => void;
onResumeRoundtable: () => void; onResumeRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void; onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void; onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void; onSavePersona: (id: number) => void;
onProceedToStep3: () => void; onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void; onBackgroundSuspend: () => void;
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean; isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void; onTriggerToast: (message: string) => void;
};
} }
export function Step2Panel(props: Step2PanelProps) { export function Step2Panel({
session,
execution,
model,
artifacts,
setters,
actions,
}: Step2PanelProps) {
const { const {
step2RoundtableDialogues, dialogues: step2RoundtableDialogues,
isInitializing, isInitializing,
isRoundTableRunning, isRoundTableRunning,
isOrchestrating, isOrchestrating,
@@ -172,6 +170,8 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs, agentConfigs,
selectedAgentId, selectedAgentId,
roles, roles,
} = session;
const {
seatFilter, seatFilter,
setSeatFilter, setSeatFilter,
parallelBatch, parallelBatch,
@@ -182,18 +182,26 @@ export function Step2Panel(props: Step2PanelProps) {
orchestrationPlan, orchestrationPlan,
dagNodeStates, dagNodeStates,
agentNameToRole, agentNameToRole,
} = execution;
const {
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading, modelsLoading,
setSelectedModel, setSelectedModel,
} = model;
const {
activeArtifactThreadId, activeArtifactThreadId,
seatStubMessages, seatStubMessages,
openArtifactFromStep, openArtifactFromStep,
openArtifactPreview, openArtifactPreview,
} = artifacts;
const {
setClarificationDraft, setClarificationDraft,
setCustomAgentInput, setCustomAgentInput,
setAgentConfigs, setAgentConfigs,
setSelectedAgentId, setSelectedAgentId,
} = setters;
const {
onPauseRoundtable, onPauseRoundtable,
onResumeRoundtable, onResumeRoundtable,
onHandleInjectToRoundtable, onHandleInjectToRoundtable,
@@ -203,7 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
onBackgroundSuspend, onBackgroundSuspend,
isViewingHistory, isViewingHistory,
onTriggerToast, onTriggerToast,
} = props; } = actions;
const step2ScrollRef = useRef<HTMLDivElement>(null); const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
@@ -375,34 +383,11 @@ export function Step2Panel(props: Step2PanelProps) {
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动 // 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false); const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。 const handleInject = useCallback((files?: File[]) => {
const [injectFiles, setInjectFiles] = useState<File[]>([]); onHandleInjectToRoundtable(files);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
},
[],
);
const removeInjectFile = useCallback((idx: number) => {
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
}, []);
const handleInject = useCallback(() => {
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
setPauseFlash(true); setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600); window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable, injectFiles]); }, [onHandleInjectToRoundtable]);
const isSandboxStreaming = const isSandboxStreaming =
isOrchestrating || isOrchestrating ||
@@ -656,166 +641,20 @@ export function Step2Panel(props: Step2PanelProps) {
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />} {!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
</div> </div>
{/* Clarification card */} <Step2ClarificationCard
{pendingClarification && ( clarification={pendingClarification}
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300"> draft={clarificationDraft}
<div className="flex items-start gap-3"> setDraft={setClarificationDraft}
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" /> onSubmit={onSubmitClarification}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{pendingClarification.clarificationType && (
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="roundtable-chat-message text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="roundtable-chat-message text-slate-800 font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
pendingClarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{pendingClarification.options.map((opt, idx) => {
const label =
typeof opt === "string"
? opt
: opt && typeof opt === "object" && "label" in (opt as object)
? String((opt as { label: unknown }).label ?? "")
: String(opt ?? "");
if (!label) return null;
return (
<button
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="roundtable-chat-message font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-end gap-2 pt-1">
<AutoGrowTextarea
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmitClarification(clarificationDraft);
}
}}
placeholder="输入你的回答…(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs leading-relaxed focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
maxHeight={280}
/> />
<button
type="button"
onClick={() => onSubmitClarification(clarificationDraft)}
disabled={!clarificationDraft.trim()}
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Console + intervention bar */} <Step2InterventionBar
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={customAgentInput} value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)} setValue={setCustomAgentInput}
onKeyDown={(e) => { hasConsensus={hasConsensus}
if (e.key === "Enter" && !e.shiftKey) { onInject={handleInject}
e.preventDefault(); onProceed={onProceedToStep3}
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/> />
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
</> </>
); );
@@ -2,6 +2,7 @@
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。 * Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
*/ */
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { import {
@@ -75,6 +76,10 @@ export function Step3FileBrowseMode({
}: Step3FileBrowseModeProps) { }: Step3FileBrowseModeProps) {
const [activeFile, setActiveFile] = useState(initialFile); const [activeFile, setActiveFile] = useState(initialFile);
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "roundtable-step3-file-browse-layout",
panelIds: ["content", "artifact"],
});
useEffect(() => { useEffect(() => {
openArtifactPreview(activeFile.threadId, activeFile.file.path); openArtifactPreview(activeFile.threadId, activeFile.file.path);
@@ -163,9 +168,10 @@ export function Step3FileBrowseMode({
<ResizablePanelGroup <ResizablePanelGroup
orientation="horizontal" orientation="horizontal"
className="h-full min-h-0 gap-3" className="h-full min-h-0 gap-3"
autoSaveId="roundtable-step3-file-browse-layout" defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
> >
<ResizablePanel defaultSize={42} minSize={26} className="min-w-0"> <ResizablePanel id="content" defaultSize={42} minSize={26} className="min-w-0">
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5"> <aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<div className="shrink-0 flex items-center justify-between gap-2 px-1"> <div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest"> <span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
@@ -312,7 +318,7 @@ export function Step3FileBrowseMode({
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" /> <ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
<ResizablePanel defaultSize={58} minSize={30} className="min-w-0"> <ResizablePanel id="artifact" defaultSize={58} minSize={30} className="min-w-0">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden"> <section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout <Step2SandboxLayout
artifactsFill artifactsFill
@@ -9,40 +9,26 @@
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、 * 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。 * 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
*/ */
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { import {
ArrowRight, ArrowRight,
CheckCircle, CheckCircle,
Database,
Download, Download,
Eye,
FileText, FileText,
Loader2, Loader2,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
Paperclip,
Pause, Pause,
RefreshCw, RefreshCw,
SendHorizontal,
Sparkles, Sparkles,
Workflow, Workflow,
X,
} from "lucide-react"; } from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel"; import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import { import {
@@ -50,23 +36,22 @@ import {
extractFlowGraph, extractFlowGraph,
FLOW_FENCE_OPEN, FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph"; } from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import {
interface SeatDelivery { CollapsedSourceCard,
sender: string; ContentModal,
avatarType: string; SeatDeliveriesView,
content: string; SeatFilesModal,
} SourceCard,
type SeatDelivery,
} from "@/roundtable-planning/components/step3-summary/Step3SummaryWorkspaceParts";
import { AgentProgressPanel } from "@/roundtable-planning/components/step3-summary/Step3SummaryAgentProgress";
interface Step3SummaryModeProps { interface Step3SummaryModeProps {
threadIds: ThreadIdMap | null; threadIds: ThreadIdMap | null;
@@ -89,7 +74,6 @@ interface Step3SummaryModeProps {
/** 切到「最终方案展示」静态页(次要入口)。 */ /** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void; onViewStatic: () => void;
} }
type SourceModal = "consensus" | "seats" | "files" | null; type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3SummaryMode({ export function Step3SummaryMode({
@@ -497,761 +481,3 @@ export function Step3SummaryMode({
</div> </div>
); );
} }
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
function SourceCard({
icon,
title,
hint,
badge,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
hint: string;
badge?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
{badge && (
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
{badge}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
</button>
);
}
function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
function AgentProgressPanel({
phase,
dialogues,
error,
busy,
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
onPause,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
mdAvailable: boolean;
/** 后端流程图抽取阶段(Phase 1)。 */
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
pendingClarification: string | null;
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
ingestAvailable: boolean;
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
pendingIngestClarification: string | null;
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
pendingFlowForIngest: boolean;
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
onManualIngest: UseStep3SummaryReturn["runIngest"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
setAskFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
};
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = askText.trim();
const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
setAskText("");
setAskFiles([]);
onAsk(text, hasFiles ? askFiles : undefined);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
</div>
</div>
<select
id="step3-summary-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化总结线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">总结失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
{(flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
</div>
)}
{flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{ingestAvailable && ingestStatus === "asking" && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
流程图已生成,是否进行数据入库?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "now", t: "现在入库" },
{ k: "later", t: "稍后入库" },
{ k: "never", t: "不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestChoice(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */}
{ingestAvailable && pendingFlowForIngest && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "generate", t: "生成流程图" },
{ k: "cancel", t: "暂不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveFlowForIngest(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
没太理解你的入库意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "start_ingest", t: "开始入库" },
{ k: "add_data", t: "补充内容后再入库" },
{ k: "other", t: "只是提问 / 改报告" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestClarification(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{askFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => removeFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={askText}
onChange={(e) => setAskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={1}
placeholder={
busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
{ingestAvailable &&
ingestStatus !== "asking" &&
ingestStatus !== "running" &&
ingestStatus !== "idle" && (
<button
type="button"
onClick={() => onManualIngest()}
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
数据入库
</button>
)}
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
>
<Paperclip className="h-4 w-4" />
</button>
{/* 暂停(仅生成中显示) */}
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前生成"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
{/* 发送(随时可发:生成中发送=打断并续问) */}
<button
type="submit"
disabled={askText.trim().length === 0 && askFiles.length === 0}
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="h-4 w-4" />
</button>
</div>
</form>
</div>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
// 经 portal 挂到 body:页面 <main> 是 `relative z-0`(浅蓝主题层叠基线),会把
// 内部 fixed 弹窗困在 z-0 层叠上下文里,被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
// 挂到 body 后脱离该上下文,z-50 才真正盖在顶栏之上,弹窗标题栏正常显示。
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
onClick={onClose}
>
<div
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>,
document.body,
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(0);
if (seatDeliveries.length === 0) {
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
}
return (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const open = openIdx === idx;
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setOpenIdx(open ? null : idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{desensitize(seat.content || "(空)")}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
null,
);
useEffect(() => {
if (seatEntries.length === 0) {
setBySeat([]);
return;
}
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatEntries]);
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
}
};
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
if (typeof window !== "undefined") {
window.open(
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
"_blank",
"noopener,noreferrer",
);
}
};
return (
<>
<ContentModal title="各席位生成文件" onClose={onClose}>
{bySeat.length === 0 ? (
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{bySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
正在查询 outputs 目录…
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</ContentModal>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</>
);
}
@@ -0,0 +1,220 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { Activity, History, Loader2, Pencil, Trash2, Upload } from "lucide-react";
import type { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
type Drafts = ReturnType<typeof useDraftPersistence>["drafts"];
export interface HistoryDropdownProps {
innerRef: MutableRefObject<HTMLDivElement | null>;
isDark: boolean;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
drafts: Drafts;
currentDraftId: string | null;
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合。 */
pendingSuspendIds: Set<string>;
renamingDraftId: string | null;
renameDraftValue: string;
setRenameDraftValue: Dispatch<SetStateAction<string>>;
onLoad: (id: string) => void;
onOpen?: () => void;
onOpenJob: (jobId: string) => void;
onDelete: (id: string) => void;
onStartRename: (draft: Drafts[number]) => void;
onCommitRename: (id: string) => void;
onCancelRename: () => void;
}
export function HistoryDropdown(props: HistoryDropdownProps) {
const {
innerRef,
isDark,
isOpen,
setIsOpen,
drafts,
currentDraftId,
pendingSuspendIds,
renamingDraftId,
renameDraftValue,
setRenameDraftValue,
onLoad,
onOpen,
onOpenJob,
onDelete,
onStartRename,
onCommitRename,
onCancelRename,
} = props;
const panelBg = isDark ? "#122347" : "#ffffff";
const currentBg = isDark ? "#1a2f5a" : "#ecfdf5";
return (
<div className="relative" ref={innerRef}>
<button
id="history-dropdown-btn"
type="button"
onClick={() =>
setIsOpen((open) => {
const next = !open;
if (next) onOpen?.();
return next;
})
}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<History className="w-3.5 h-3.5 text-slate-500" />
历史记录
<span className="text-[10px] font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{drafts.length}
</span>
</button>
{isOpen && (
<div
className="rt-history-dropdown-panel absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto border border-slate-200 rounded-2xl shadow-xl z-[1000] dark:border-border"
style={{
background: panelBg,
backgroundColor: panelBg,
backgroundImage: "none",
opacity: 1,
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
<div
className="rt-history-dropdown-header px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 z-10 dark:border-border"
style={{
background: panelBg,
backgroundColor: panelBg,
backgroundImage: "none",
opacity: 1,
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
<span className="text-xs font-black text-slate-800 tracking-wide dark:text-foreground">
历史草稿
</span>
<span className="text-[10px] font-mono text-slate-400">{drafts.length} 条</span>
</div>
{drafts.length === 0 ? (
<div className="px-4 py-6 text-[11px] text-slate-400 italic text-center">
暂无历史。点击「保存草稿」或走完一步会自动保存到这里。
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-border">
{drafts.map((draft) => {
const isCurrent = draft.id === currentDraftId;
const isRenaming = draft.id === renamingDraftId;
const isSuspending =
pendingSuspendIds.has(draft.id) || draft.status === "queued";
const isActiveJob =
(draft.status === "running" || draft.status === "awaiting_input") &&
!!draft.jobId;
const openOrLoad = () =>
isActiveJob && draft.jobId ? onOpenJob(draft.jobId) : onLoad(draft.id);
return (
<li
key={draft.id}
className="px-3 py-2.5"
style={isCurrent ? { backgroundColor: currentBg } : undefined}
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
{isRenaming ? (
<input
type="text"
autoFocus
value={renameDraftValue}
onChange={(event) => setRenameDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") onCommitRename(draft.id);
if (event.key === "Escape") onCancelRename();
}}
onBlur={() => onCommitRename(draft.id)}
className="w-full text-[11.5px] font-semibold text-slate-800 border border-slate-300 rounded px-2 py-1 outline-none focus:border-primary dark:bg-background dark:text-foreground"
/>
) : (
<button
type="button"
onClick={openOrLoad}
className="block w-full text-left"
title={isActiveJob ? "点击查看进度" : "点击加载"}
>
<span className="text-[11.5px] font-semibold text-slate-800 line-clamp-2 dark:text-foreground">
{draft.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{isSuspending ? (
<span className="inline-flex items-center gap-1 text-sky-500 font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
挂起中
</span>
) : draft.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
</span>
) : draft.status === "awaiting_input" ? (
<span className="inline-flex items-center gap-1 text-amber-500 font-semibold">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
待澄清
</span>
) : draft.status === "error" ? (
<span className="text-rose-500 font-semibold">出错</span>
) : (
<span>Step {draft.furthestStep}</span>
)}
<span className="text-slate-300">·</span>
{new Date(draft.updatedAt).toLocaleString("zh-CN", { hour12: false })}
</span>
</button>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{!isRenaming && (
<>
<button
type="button"
onClick={openOrLoad}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title={isActiveJob ? "查看进度" : "加载"}
>
{isActiveJob ? (
<Activity className="w-3.5 h-3.5 text-primary" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
onClick={() => onStartRename(draft)}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title="重命名"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(draft.id)}
className="p-1 text-slate-500 hover:text-rose-600 hover:bg-rose-50 rounded transition"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,549 @@
import { useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronRight,
ChevronUp,
Clipboard,
Cpu,
Filter,
History,
Layers,
Target,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { cn } from "@/lib/utils";
import { listChains } from "@/roundtable-planning/api/chains";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
const SEAT_CHIP_PALETTE = [
"bg-rose-50 text-rose-700 border-rose-200",
"bg-amber-50 text-amber-700 border-amber-200",
"bg-teal-50 text-teal-700 border-teal-200",
"bg-purple-50 text-purple-700 border-purple-200",
"bg-sky-50 text-sky-700 border-sky-200",
"bg-emerald-50 text-emerald-700 border-emerald-200",
];
/**
* 把业务链席位按 `stages`(分层并行编排)分组:组内并行、组间串行;无 `stages`
* (线性链)时每个席位单独成一层。仅供左栏「展开关系图」做编排示意展示。
*/
function getChainStageGroups(chain: BusinessChain): BusinessChain["seats"][] {
const byId = new Map(chain.seats.map((s) => [s.agent_id, s]));
if (chain.stages && chain.stages.length > 0) {
return chain.stages
.map((stage) =>
stage
.map((id) => byId.get(id))
.filter((s): s is BusinessChain["seats"][number] => Boolean(s)),
)
.filter((stage) => stage.length > 0);
}
return chain.seats.map((s) => [s]);
}
/** 分析记录「按业务链筛选」的两个特殊值:全部 / 无业务链(自由模式)。 */
const RECORD_FILTER_ALL = "__all__";
const RECORD_FILTER_FREE = "__free__";
/**
* 需求 0616 #2:多智能体会商 Step1(落地)左栏入口栏。
* - 业务链:仅公共链条(``listChains("public")``)前 3 个;点击以该链席位启动会商。
* - 分析记录:圆桌历史草稿(``useRoundtableDrafts``);点击打开对应历史会商分析。
* 进入研讨(Step 2/3)后左栏恢复为「参与角色 / 任务概览」工作面板。
*/
export function RoundtableEntryRail({
selectedChainId,
onSelectChain,
onOpenDraft,
activeDraftId,
taskId,
businessCode,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
onOpenDraft: (draftId: string) => void;
activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null;
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
businessCode?: string | null;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
// 无业务链的草稿(自由 / recommend 模式)归入「自由模式」。
const [chainFilter, setChainFilter] = useState<string>(RECORD_FILTER_ALL);
const chainFilterOptions = useMemo(() => {
const titles = new Set<string>();
let hasFree = false;
for (const rec of records) {
const title = rec.chainTitle?.trim();
if (title) titles.add(title);
else hasFree = true;
}
const opts = Array.from(titles)
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((title) => ({ value: title, label: title }));
if (hasFree) opts.push({ value: RECORD_FILTER_FREE, label: "自由模式" });
return opts;
}, [records]);
const filteredRecords = useMemo(() => {
if (chainFilter === RECORD_FILTER_ALL) return records;
if (chainFilter === RECORD_FILTER_FREE)
return records.filter((rec) => !rec.chainTitle?.trim());
return records.filter((rec) => rec.chainTitle?.trim() === chainFilter);
}, [records, chainFilter]);
// 草稿刷新后所选业务链可能已不存在(被删/改名)→ 回落到「全部」,避免列表空白无解。
useEffect(() => {
if (
chainFilter !== RECORD_FILTER_ALL &&
!chainFilterOptions.some((opt) => opt.value === chainFilter)
) {
setChainFilter(RECORD_FILTER_ALL);
}
}, [chainFilter, chainFilterOptions]);
// 深链接(taskId)模式:左上角不显示「业务链选择」(链条已由 businessCode 自动决定),改为展示
// 当前任务(rwfx/3qfx)或当前行动(xdfx)。xdfx 的根是「行动」,故展示行动名称 + 叙事。
const deeplink = Boolean(taskId);
const isXdfx = businessCode === "7BF";
const taskDetail = deeplink && !isXdfx ? readCopTaskDetail() : null;
const actionDetail = deeplink && isXdfx ? readActionDetail() : null;
useEffect(() => {
// 深链接模式不展示业务链选择 → 不必拉公共链条。
if (deeplink) {
void refresh();
return;
}
let cancelled = false;
void listChains("public")
.then((all) => {
if (!cancelled) setChains(all);
})
.catch(() => {
/* 业务链非关键,静默忽略 */
});
void refresh();
return () => {
cancelled = true;
};
}, [refresh, deeplink]);
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Target className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">行动名称</p>
<p className="mt-0.5 text-xs font-semibold leading-relaxed text-slate-800">
{actionDetail?.actionName?.trim() || "(未获取到行动名称)"}
</p>
</div>
</div>
) : (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Clipboard className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
</div>
<div className="space-y-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">任务名称</p>
<p className="mt-0.5 text-xs font-semibold leading-relaxed text-slate-800">
{taskDetail?.taskName?.trim() || "(未获取到任务名称)"}
</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">任务描述</p>
<p className="mt-0.5 whitespace-pre-wrap text-[11px] leading-relaxed text-slate-600">
{taskDetail?.taskContent?.trim() || "(未获取到任务描述)"}
</p>
</div>
</div>
</div>
)
) : (
/* 业务链(公共列表,内部滚动) */
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Layers className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
<span className="ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm font-bold tabular-nums leading-none text-neutral-500">
{chains.length}
</span>
<button
type="button"
onClick={() => setChainSectionExpanded((v) => !v)}
title={chainSectionExpanded ? "收起业务链" : "展开业务链"}
aria-expanded={chainSectionExpanded}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[#E6C9C4] bg-white text-neutral-400 transition-colors hover:border-primary/40 hover:text-primary"
>
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform duration-300 ease-out",
chainSectionExpanded && "rotate-180",
)}
/>
</button>
</div>
<div
className="grid overflow-hidden transition-all duration-300 ease-out"
style={{
gridTemplateRows: chainSectionExpanded ? "1fr" : "0fr",
marginTop: chainSectionExpanded ? 12 : 0,
opacity: chainSectionExpanded ? 1 : 0,
}}
>
<div className="min-h-0 overflow-hidden">
{chains.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
) : (
<div className="max-h-[292px] overflow-y-auto pr-1 -mr-1 custom-scrollbar">
<div className="flex flex-col gap-2.5 py-0.5">
{chains.map((chain) => {
const selected = selectedChainId === chain.id;
const graphOpen = expandedChainId === chain.id;
const stageGroups = getChainStageGroups(chain);
let seatColorIndex = 0;
return (
<div
key={chain.id}
className={cn(
"min-h-[88px] rounded-xl border transition-all duration-200",
selected
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
)}
>
<button
type="button"
onClick={() => onSelectChain(chain)}
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
aria-pressed={selected}
className="w-full rounded-xl p-3 text-left"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{/* 单选圆点 */}
<span
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border transition-all",
selected
? "border-primary bg-primary"
: "border-neutral-300 bg-white",
)}
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-white" />}
</span>
<span
className={cn(
"truncate text-xs font-semibold",
selected ? "text-primary" : "text-neutral-800",
)}
>
{chain.title}
</span>
</div>
<span
className={cn(
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold",
selected
? "bg-primary/10 text-primary"
: "bg-neutral-100 text-neutral-500",
)}
>
{chain.seats.length} 席
</span>
</div>
{chain.description ? (
<p className="mt-1.5 line-clamp-2 text-[10.5px] leading-relaxed text-neutral-500">
{chain.description}
</p>
) : (
<p className="mt-1.5 text-[10px] font-medium text-neutral-400">
{selected ? "已选 · 再次点击取消" : "点击勾选该业务链"}
</p>
)}
</button>
{/* 关系图示例:选中后可展开,把该链席位智能体用小图标排成编排示意 */}
{selected && (
<div className="mx-3 mb-3 border-t border-dashed border-[#E8D0CC]/60 pt-2.5">
<button
type="button"
onClick={() =>
setExpandedChainId(graphOpen ? null : chain.id)
}
className="flex w-full items-center justify-center gap-1 rounded-full border border-primary/15 bg-primary/5 px-3 py-1 text-[10.5px] font-semibold text-primary transition-colors hover:bg-primary/10"
>
<Layers className="h-3 w-3" />
<span>{graphOpen ? "收起关系图" : "展开关系图"}</span>
{graphOpen ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{graphOpen && (
<div className="mt-2 rounded-xl border border-dashed border-[#E8D0CC]/60 bg-[#FAF6F5] p-2.5">
{stageGroups.length === 0 ? (
<p className="py-2 text-center text-[10px] text-neutral-400">
该业务链暂无智能体席位
</p>
) : (
<div className="flex flex-row flex-wrap items-center justify-center gap-1">
{stageGroups.flatMap((stage, si) => {
// 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
// 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
const items: React.ReactNode[] = [];
if (si > 0) {
items.push(
<ChevronRight
key={`arrow-${si}`}
className="h-3 w-3 shrink-0 text-neutral-300"
/>,
);
}
items.push(
<div
key={`stage-${si}`}
className="flex flex-col items-center gap-1"
>
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-col items-center gap-1.5">
{stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
SEAT_CHIP_PALETTE.length
];
return (
<div
key={`${si}-${sj}-${seat.agent_id}`}
className="group/seat relative"
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border",
palette,
)}
>
<Cpu className="h-3 w-3" />
</span>
{/* 悬停浮层显示名称 / 描述 */}
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 group-hover/seat:block">
<div className="max-w-[180px] rounded-lg bg-neutral-900 px-2 py-1 text-white shadow-lg">
<div className="whitespace-nowrap text-[10px] font-semibold">
{seat.name}
</div>
{seat.description && (
<div className="mt-0.5 whitespace-normal text-[9px] leading-snug text-neutral-300">
{seat.description}
</div>
)}
</div>
<div className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-neutral-900" />
</div>
</div>
);
})}
</div>
</div>,
);
return items;
})}
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* 分析记录(圆桌历史草稿) */}
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] flex-1 flex flex-col min-h-[200px] overflow-hidden">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<History className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
{/* 按业务链筛选 —— 仅当存在多于 1 个可选业务链时才显示图标按钮。 */}
{chainFilterOptions.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="按业务链筛选"
aria-label="按业务链筛选"
className={cn(
"ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-colors",
chainFilter === RECORD_FILTER_ALL
? "border-[#ECE7E4] text-neutral-400 hover:border-primary/30 hover:text-primary"
: "border-primary/30 bg-primary/5 text-primary",
)}
>
<Filter className="h-3 w-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuLabel className="text-[16px] text-neutral-500">
按业务链筛选
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={chainFilter}
onValueChange={setChainFilter}
>
<DropdownMenuRadioItem value={RECORD_FILTER_ALL} className="text-[16px]">
全部
</DropdownMenuRadioItem>
{chainFilterOptions.map((opt) => (
<DropdownMenuRadioItem
key={opt.value}
value={opt.value}
className="text-[16px]"
>
<span className="truncate">{opt.label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{records.length === 0 ? (
<p className="flex-1 text-xs text-neutral-400 text-center py-8">暂无分析记录</p>
) : filteredRecords.length === 0 ? (
<p className="flex-1 text-xs text-neutral-400 text-center py-8">
该业务链下暂无分析记录
</p>
) : (
<>
<div className="space-y-2 overflow-y-auto flex-1 px-1 py-0.5 custom-scrollbar">
{filteredRecords.map((rec) => {
// 已完成:进行到第 3 步(执行过方案总结报告)
const completed = rec.furthestStep === 3;
// 选中:当前已加载的草稿(正在查看的那条)
const active = activeDraftId === rec.id;
return (
<button
key={rec.id}
type="button"
onClick={() => onOpenDraft(rec.id)}
title={rec.title}
aria-current={active}
className={cn(
"group/rec w-full rounded-[5px] border px-3 py-2.5 text-left transition-all duration-200",
active
? "border-primary/40 bg-primary/5 ring-1 ring-primary/20 shadow-[0_2px_10px_rgba(45,37,34,0.05)]"
: "border-[#ECE7E4] bg-white hover:border-primary/30 hover:bg-neutral-50 hover:shadow-[0_2px_10px_rgba(45,37,34,0.05)]",
)}
>
{/* 上:标题(左) + 状态标签(右上、放大) */}
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"truncate text-[13px] font-semibold",
active
? "text-primary"
: "text-neutral-800 group-hover/rec:text-primary",
)}
>
{rec.title}
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-[5px] px-2 py-1 text-[11px] font-semibold",
completed
? "bg-emerald-50 text-emerald-600"
: "bg-amber-50 text-amber-600",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
completed ? "bg-emerald-500" : "bg-amber-500",
)}
/>
{completed ? "已完成" : "未完成"}
</span>
</div>
{/* 下:时间(左下) + 业务链(右下,无链条显示「自由模式」) */}
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-neutral-400 shrink-0">
{formatTime(rec.updatedAt)}
</span>
<span
className="max-w-[60%] truncate rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
title={rec.chainTitle || "自由模式"}
>
{rec.chainTitle || "自由模式"}
</span>
</div>
</button>
);
})}
</div>
<div className="mt-2 pt-2 border-t border-neutral-100 text-center text-[11px] text-neutral-400">
{chainFilter === RECORD_FILTER_ALL
? `共 ${records.length} 条分析记录`
: `筛选出 ${filteredRecords.length} / ${records.length} 条分析记录`}
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,256 @@
import { useEffect, useRef } from "react";
import {
Clipboard,
PanelLeftOpen,
Users,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { cn } from "@/lib/utils";
import type { DraftStep2RunSnapshot } from "@/roundtable-planning/api/drafts";
import { Man1Avatar } from "@/roundtable-planning/components/Avatars";
import {
IntentSectionIcon,
IntentSummaryCard,
} from "@/roundtable-planning/components/planning/RoundtableRightSidebar";
import { useStep1Intent } from "@/roundtable-planning/hooks/useStep1Intent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import type { RoleEntry } from "@/roundtable-planning/lib/roundtable-constants";
import {
seatFilterForRole,
USER_SEAT_FILTER,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { RoundtableEntryRail } from "@/roundtable-planning/components/planning/RoundtableEntryRail";
import {
CollapsedSidebarSection,
HumanParticipationCard,
RoleAvatarBadge,
RoleRow,
Step2RunSwitcher,
TaskOverviewCard,
} from "@/roundtable-planning/components/planning/RoundtableSidebarCards";
export interface LeftSidebarProps {
currentStep: number;
roles: RoleEntry[];
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
budgetLimit: number;
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
/** 需求 0616 #2:当前已勾选的业务链 id(高亮用),来自 autoChainResult.chain.id。 */
selectedChainId: string | null;
/** 需求 0616 #2:Step1 落地左栏「业务链」点击 → 勾选该链(不直接进研讨)。 */
onSelectChain: (chain: BusinessChain) => void;
/** 需求 0616 #2:Step1 落地左栏「分析记录」点击 → 打开历史会商分析(加载草稿)。 */
onOpenDraft: (draftId: string) => void;
/** 当前已加载草稿 id(分析记录选中高亮用),来自 drafts.currentDraftId。 */
activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null;
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
entryRailBusinessCode?: string | null;
}
export function Step2SandboxSidebarSync({
currentStep,
sidebarExpanded,
setSidebarExpanded,
}: {
currentStep: number;
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { open } = useArtifacts();
const prevOpenRef = useRef(open);
useEffect(() => {
if (currentStep !== 2) {
prevOpenRef.current = open;
return;
}
if (open && !prevOpenRef.current && sidebarExpanded) {
setSidebarExpanded(false);
}
prevOpenRef.current = open;
}, [currentStep, open, setSidebarExpanded, sidebarExpanded]);
return null;
}
export function LeftSidebar({
currentStep,
roles,
agentNameToRole,
budgetLimit: _budgetLimit,
intentReady,
sidebarExpanded,
setSidebarExpanded,
step2Runs,
activeStep2RunId,
onSelectStep2Run,
seatFilter,
setSeatFilter,
selectedChainId,
onSelectChain,
onOpenDraft,
activeDraftId,
entryRailTaskId,
entryRailBusinessCode,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
const dzRef = useDesensitizeSubtree<HTMLElement>();
const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
if (currentStep === 3) return null;
return (
<section
ref={dzRef}
id="left-sidebar"
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
isStep2 ? "overflow-y-auto" : "overflow-hidden",
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
)}
>
{isStep2 && !sidebarExpanded && (
<button
type="button"
onClick={() => setSidebarExpanded(true)}
title="展开侧栏"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
)}
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
</div>
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleRow
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</div>
<HumanParticipationCard
active={seatFilter === USER_SEAT_FILTER}
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
/>
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={onSelectStep2Run}
/>
<TaskOverviewCard currentStep={currentStep} />
</>
) : (
<>
<CollapsedSidebarSection title="意图理解">
<IntentSectionIcon className="h-5 w-5" />
</CollapsedSidebarSection>
<CollapsedSidebarSection title="参与角色">
<div className="flex flex-col items-center gap-2">
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="人工参与">
<button
type="button"
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
aria-pressed={seatFilter === USER_SEAT_FILTER}
title={
seatFilter === USER_SEAT_FILTER
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
seatFilter === USER_SEAT_FILTER &&
"ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
<Man1Avatar size="h-8 w-8" />
</button>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="任务概览">
<Clipboard className="h-5 w-5 text-primary" strokeWidth={2.5} />
</CollapsedSidebarSection>
</>
)
) : (
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
/>
)}
</section>
);
}
/** 「展开关系图」示例芯片的轮换配色(仅作视觉区分,无业务语义)。 */
@@ -0,0 +1,73 @@
import {
Activity,
BookOpen,
ChevronRight,
Layers,
Target,
Users,
Zap,
} from "lucide-react";
import type { PurposeLevel } from "@/core/auth/task-purpose";
import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-graph";
/**
* 深链 taskId 模式 · Step 1 消息列表头部左侧的「策划流程」小图。
*
* 取代默认标题(任务意图理解),按目的树层级(目的 → 行为体 → … → 叙事)横向排列
* 彩色小图标、图标间用箭头串联,配色与流程图画布节点一致(FLOW_NODE_TYPE_META)。
* 点击整条 → 打开任务目的流程图弹窗看完整大图(不再单独放「查看」按钮)。
* 无层级数据时由调用方退回默认标题(此处 levels 必非空)。
*/
/** 各层级(流程图节点类型)对应的小图标;未列出的类型回退 Layers。 */
const LEVEL_ICON: Partial<Record<FlowNodeType, React.ComponentType<{ className?: string }>>> = {
stage: Target, // 目的
role: Users, // 行为体
task: Activity, // 预期行为
decision: Zap, // 驱动因素
deliverable: BookOpen, // 叙事
};
export function PurposeFlowPreview({
levels,
onOpen,
dark = false,
}: {
levels: PurposeLevel[];
onOpen: () => void;
dark?: boolean;
}) {
if (levels.length === 0) return null;
const meta = flowNodeMeta(dark);
return (
<button
type="button"
onClick={onOpen}
title="点击查看完整策划流程图"
className="group/flow custom-scrollbar flex min-w-0 cursor-pointer items-center gap-2 overflow-x-auto rounded-lg text-left transition-opacity hover:opacity-90"
>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold text-slate-500">
<Layers className="h-3.5 w-3.5 text-primary" />
原任务已有结果
</span>
{levels.map((lv, i) => {
const m = meta[lv.type];
const Icon = LEVEL_ICON[lv.type] ?? Layers;
return (
<span key={`${lv.label}-${i}`} className="flex shrink-0 items-center gap-1">
{i > 0 && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-300" />}
<span
title={lv.label}
style={{ background: m.bg, borderColor: m.border, color: m.text }}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold"
>
<Icon className="size-3 shrink-0" />
{lv.label}
</span>
</span>
);
})}
</button>
);
}
@@ -0,0 +1,448 @@
import { useState } from "react";
import type React from "react";
import {
Check,
ChevronDown,
ChevronUp,
Clock,
Cpu,
PanelLeftClose,
PanelLeftOpen,
Pencil,
Plus,
Trash2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import { useStep1Intent } from "@/roundtable-planning/hooks/useStep1Intent";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
interface ModelSelectorCardProps {
selectedModel: string;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
modelsLoading: boolean;
}
function ModelSelectorCard({
selectedModel,
setSelectedModel,
availableModels,
modelsLoading,
}: ModelSelectorCardProps) {
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
</div>
<select
id="global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
}
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name}
</option>
))}
</select>
</div>
);
}
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[]>>,
index: number,
value: string,
) => setter((previous) => previous.map((item, i) => (i === index ? value : item)));
const removeRow = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
index: number,
) => setter((previous) => previous.filter((_, i) => i !== index));
const addRow = (setter: React.Dispatch<React.SetStateAction<string[]>>) =>
setter((previous) => [...previous, ""]);
const handleSave = () => {
if (objectiveInvalid) return;
onSave({
objective: objective.trim(),
constraints: constraints.map((constraint) => constraint.trim()).filter(Boolean),
assumptions: assumptions.map((assumption) => assumption.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, index) => (
<div key={index} className="flex items-center gap-1.5">
<input
value={row}
onChange={(event) => updateRow(setter, index, event.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, index)}
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={(event) => setObjective(event.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;
collapsible?: boolean;
sidebarToggle?: {
expanded: boolean;
onToggle: () => void;
};
onSave?: (next: IntentSummary) => void;
}
export function IntentSummaryCard({
intentReady,
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)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<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">
{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">
{intentReady ? (
<>
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
<li className="flex items-start gap-2 leading-relaxed">
<span className="text-primary text-base leading-none pt-0.5">•</span>
<span
className={cn(
"min-w-0 leading-relaxed",
collapsible && !expanded && "line-clamp-3",
)}
>
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
</span>
</li>
</ul>
{collapsible && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="inline-flex items-center gap-0.5 text-xs font-bold text-slate-500 hover:text-primary transition-colors"
aria-expanded={expanded}
>
{expanded ? (
<>
收起
<ChevronUp className="w-3.5 h-3.5" />
</>
) : (
<>
展开
<ChevronDown className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
)}
{showFullDetail && (
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
{intentReady.constraints.map((constraint, index) => (
<li key={`c-${index}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-primary text-base leading-none">•</span>
<span>
<b className="text-slate-900 font-bold">约束</b>: {constraint}
</span>
</li>
))}
{(intentReady.assumptions ?? []).map((assumption, index) => (
<li key={`a-${index}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-slate-400 text-base leading-none">•</span>
<span className="text-slate-500">
<b className="text-slate-700 font-bold">假设</b>: {assumption}
</span>
</li>
))}
</ul>
)}
</>
) : (
<p className="text-xs text-slate-400 italic leading-relaxed">
等待任务理解智能体澄清完成...
</p>
)}
</div>
</div>
);
}
export function IntentSectionIcon({ className }: { className?: string }) {
return (
<svg
className={cn("text-primary shrink-0", className)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
}
interface RightSidebarProps extends ModelSelectorCardProps {
currentStep: number;
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
triggerToast: (message: string) => void;
onSaveIntent: (next: IntentSummary) => void;
}
export function RightSidebar({
currentStep,
selectedModel,
setSelectedModel,
availableModels,
modelsLoading,
intentReady,
timelineEntries,
triggerToast,
onSaveIntent,
}: RightSidebarProps) {
const desensitizedRef = useDesensitizeSubtree<HTMLElement>();
if (currentStep !== 1) return null;
return (
<section
ref={desensitizedRef}
id="right-sidebar"
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
>
<ModelSelectorCard
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
/>
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
<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]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3 shrink-0">
<Clock className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">澄清记录</h2>
</div>
<div className="flex-1 overflow-y-auto pr-1 font-normal custom-scrollbar">
<div className="relative ml-2 space-y-4 border-l border-red-50 pb-2 pl-4 pt-1.5">
{timelineEntries.map((entry) => (
<div
key={entry.id}
className="relative rounded-lg px-1.5 py-1 transition-colors hover:bg-slate-50/80"
>
<div className="absolute -left-[20.5px] top-2 h-3 w-3 rounded-full bg-primary border-2 border-white ring-2 ring-[var(--brand-200)]" />
<div className="grid grid-cols-[68px_minmax(0,1fr)] gap-x-2">
<span className="font-mono text-xs leading-5 tracking-tight text-slate-400">
{entry.time}
</span>
<div className="min-w-0">
<h4 className="break-words text-sm font-bold leading-5 tracking-tight text-slate-800">
{entry.title}
</h4>
<p className="mt-1 break-words text-xs leading-5 text-slate-500">
{entry.desc}
</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="shrink-0 pt-2.5 border-t border-slate-100 flex items-center justify-between text-xs mt-2.5">
<span className="text-slate-400 font-medium">共 {timelineEntries.length} 条记录</span>
<button
onClick={() =>
triggerToast(`正在渲染全部 ${timelineEntries.length} 个历史事件归档`)
}
className="text-primary hover:text-[var(--brand-900)] font-bold hover:underline"
>
查看全部 &gt;
</button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,411 @@
import {
Activity,
Check,
CheckCircle,
Clipboard,
ListOrdered,
Loader2,
} from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import type { DraftStep2RunSnapshot } from "@/roundtable-planning/api/drafts";
import { getAvatar, Man1Avatar } from "@/roundtable-planning/components/Avatars";
import type { RoleEntry } from "@/roundtable-planning/lib/roundtable-constants";
export function Step2RunSwitcher({
runs,
activeRunId,
onSelect,
}: {
runs: DraftStep2RunSnapshot[];
activeRunId: string | null;
onSelect: (runId: string) => void;
}) {
if (runs.length === 0) return null;
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const sourceLabel = (run: DraftStep2RunSnapshot) => {
if (run.source === "chain") return "业务链条";
if (run.source === "recommend") return "智能体推荐";
return "历史结果";
};
const statusLabel = (run: DraftStep2RunSnapshot) => {
if (run.step3) return "已生成报告";
if (run.hasConsensus) return "已达成共识";
if ((run.step2RoundtableDialogues?.length ?? 0) > 0) return "研讨记录";
return "待开始";
};
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<ListOrdered className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">生成结果</h2>
</div>
<div className="space-y-2">
{runs.map((run) => {
const active = run.id === activeRunId;
return (
<button
key={run.id}
type="button"
onClick={() => onSelect(run.id)}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
active
? "border-primary/40 bg-primary/5 text-slate-900"
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-extrabold">{run.title}</span>
{active ? (
<CheckCircle className="h-3.5 w-3.5 shrink-0 text-primary" />
) : null}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-semibold">
<span className="truncate text-slate-400">{sourceLabel(run)}</span>
<span className="shrink-0 text-slate-400">{formatTime(run.createdAt)}</span>
</div>
<div className="mt-2 flex items-center gap-1.5">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
run.step3
? "bg-emerald-500"
: run.hasConsensus
? "bg-primary"
: "bg-slate-300",
)}
/>
<span className="text-[10px] font-bold text-slate-500">{statusLabel(run)}</span>
</div>
</button>
);
})}
</div>
</div>
);
}
export function TaskOverviewCard({ currentStep }: { currentStep: number }) {
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">任务概览</h2>
</div>
<div className="space-y-3">
<div>
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
当前节点
</span>
<div className="flex items-center justify-between gap-2.5">
<span className="text-sm font-bold text-slate-800">初始任务</span>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
currentStep === 1
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
: currentStep === 2
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
}`}
>
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
</span>
</div>
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
围绕某复杂任务制定规划方案
</p>
</div>
</div>
</div>
);
}
export function HumanParticipationCard({
active = false,
onSelect,
}: {
/** 当前筛选是否命中「用户」(命中则高亮)。 */
active?: boolean;
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
onSelect?: () => void;
} = {}) {
const clickable = Boolean(onSelect);
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
</div>
<div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)}
>
<div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
<span className="whitespace-nowrap text-xs font-bold leading-none text-emerald-600">在线</span>
</div>
</div>
</div>
);
}
export function CollapsedSidebarSection({
title,
children,
className,
}: {
title: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
title={title}
className={cn(
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
className,
)}
>
{children}
</div>
);
}
function renderRoleAvatar(
role: RoleEntry,
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>,
size: string,
) {
if (role.type === "human") {
return <Man1Avatar size={size} />;
}
return getAvatar(
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
size,
);
}
export function RoleAvatarBadge({
role,
agentNameToRole,
size = "h-7 w-7",
showSpeakingAnimation = false,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
size?: string;
showSpeakingAnimation?: boolean;
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
active?: boolean;
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
onSelect?: () => void;
}) {
const isOngoing = role.status === "研讨中";
const isDone = role.status === "已完成";
const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
const inner = (
<div
className={cn(
"relative shrink-0 rounded-full",
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
{animate && (
<span className="pointer-events-none absolute inset-0 rounded-full border-2 border-primary/35 animate-ping" />
)}
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
{renderRoleAvatar(role, agentNameToRole, size)}
</div>
{/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */}
{isDone && (
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 ring-2 ring-white">
<Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
</span>
)}
</div>
);
if (!clickable) {
return <div title={`${desensitize(role.name)} · ${role.status}`}>{inner}</div>;
}
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${desensitize(role.name)} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
{inner}
</button>
);
}
export function RoleRow({
role,
agentNameToRole,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
active?: boolean;
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
onSelect?: () => void;
}) {
const isActive =
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线";
const clickable = Boolean(onSelect);
return (
<div
id={`role-item-${role.id}`}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${desensitize(role.name)} 的发言`
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
? "bg-amber-50/60 border border-amber-100"
: "opacity-60",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active &&
"ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100",
)}
>
<div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
{desensitize(role.name)}
</span>
{role.type === "agent" && (
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
{isOngoing ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary/60 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-primary"></span>
</span>
) : role.status === "在线" ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
) : role.status === "已完成" ? (
<Check className="h-3.5 w-3.5 text-emerald-500 font-bold" />
) : isBootingUp ? (
<Loader2 className="h-3 w-3 text-amber-500 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
)}
<span
className={`whitespace-nowrap text-xs font-semibold leading-none ${
isOngoing
? "text-[var(--brand-900)]"
: role.status === "在线"
? "text-emerald-600 font-bold"
: role.status === "已完成"
? "text-emerald-600"
: isBootingUp
? "text-amber-600"
: "text-slate-400"
}`}
>
{role.status}
</span>
</div>
</div>
);
}
@@ -0,0 +1,225 @@
import { useCallback, useRef, useState } from "react";
import type React from "react";
import { FileIcon, HelpCircle, Paperclip, Sparkles, X } from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function Step2ClarificationCard({
clarification,
draft,
setDraft,
onSubmit,
}: {
clarification: ClarificationPayload | null;
draft: string;
setDraft: React.Dispatch<React.SetStateAction<string>>;
onSubmit: (answer: string) => void;
}) {
if (!clarification) return null;
return (
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
<div className="flex items-start gap-3">
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{clarification.clarificationType && (
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{clarification.clarificationType}
</span>
)}
</div>
{clarification.context && (
<p className="roundtable-chat-message text-amber-800 italic">
{clarification.context}
</p>
)}
<p className="roundtable-chat-message text-slate-800 font-medium whitespace-pre-wrap break-words">
{clarification.question}
</p>
{Array.isArray(clarification.options) && clarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{clarification.options.map((option, index) => {
const label =
typeof option === "string"
? option
: option && typeof option === "object" && "label" in option
? String((option as { label: unknown }).label ?? "")
: String(option ?? "");
if (!label) return null;
return (
<button
key={`${index}-${label}`}
type="button"
onClick={() => onSubmit(label)}
className="roundtable-chat-message font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{clarification.allowCustom !== false && (
<div className="flex items-end gap-2 pt-1">
<AutoGrowTextarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit(draft);
}
}}
placeholder="输入你的回答…(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs leading-relaxed focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
maxHeight={280}
/>
<button
type="button"
onClick={() => onSubmit(draft)}
disabled={!draft.trim()}
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
);
}
export function Step2InterventionBar({
value,
setValue,
hasConsensus,
onInject,
onProceed,
}: {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
hasConsensus: boolean;
onInject: (files?: File[]) => void;
onProceed: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const handlePickFiles = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(event.target.files ?? []);
if (picked.length > 0) {
setFiles((previous) => {
const seen = new Set(previous.map((file) => `${file.name}:${file.size}`));
return [
...previous,
...picked.filter((file) => !seen.has(`${file.name}:${file.size}`)),
];
});
}
event.target.value = "";
}, []);
const removeFile = useCallback((index: number) => {
setFiles((previous) => previous.filter((_, i) => i !== index));
}, []);
const handleInject = useCallback(() => {
onInject(files.length > 0 ? files : undefined);
setFiles([]);
}, [files, onInject]);
return (
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => removeFile(index)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickFiles}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceed}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import { ChevronRight } from "lucide-react";
export interface StepNavigationProps {
currentStep: number;
intentReady: object | null;
hasConsensus: boolean;
onStepClick: (step: number) => void;
triggerToast: (msg: string) => void;
}
export function StepNavigation({
currentStep,
intentReady,
hasConsensus,
onStepClick,
triggerToast,
}: StepNavigationProps) {
const step2Locked = !intentReady;
const step3Locked = !hasConsensus;
return (
<div className="flex items-center gap-1.5 rounded-2xl border border-slate-200/50 bg-slate-50 p-1.5 dark:border-border dark:bg-muted">
<StepButton
index={1}
label="任务意图理解"
active={currentStep === 1}
locked={false}
onClick={() => onStepClick(1)}
/>
<ChevronRight className="h-3 w-3 text-slate-300 dark:text-muted-foreground" />
<StepButton
index={2}
label="多智能体研讨"
active={currentStep === 2}
locked={step2Locked}
onClick={() => {
if (step2Locked) {
triggerToast("请先在第一步完成任务理解,确认[INTENT_READY]信号后再进入");
return;
}
onStepClick(2);
}}
/>
<ChevronRight className="h-3 w-3 text-slate-300 dark:text-muted-foreground" />
<StepButton
index={3}
label="方案结果展示"
active={currentStep === 3}
locked={step3Locked}
onClick={() => {
if (step3Locked) {
triggerToast(
intentReady
? "请先在第二步达成共识(总控自然收敛)再进入"
: "请先依次完成第一步和第二步",
);
return;
}
onStepClick(3);
}}
/>
</div>
);
}
interface StepButtonProps {
index: number;
label: string;
active: boolean;
locked: boolean;
onClick: () => void;
}
function StepButton({ index, label, active, locked, onClick }: StepButtonProps) {
return (
<button
id={`step-${index}-btn`}
type="button"
onClick={onClick}
disabled={locked}
aria-disabled={locked}
title={locked ? "请先完成上一步" : undefined}
className={`flex items-center gap-2 px-4 py-1.5 rounded-xl text-xs font-semibold tracking-wide transition-all relative ${
active
? "bg-white text-primary shadow-sm font-bold scale-[1.02] dark:bg-background"
: locked
? "text-slate-300 cursor-not-allowed dark:text-slate-600"
: "text-slate-500 hover:text-slate-800 dark:text-muted-foreground dark:hover:text-foreground"
}`}
>
<span
className={`w-5 h-5 flex items-center justify-center rounded-full text-[10px] font-bold ${
active
? "bg-primary text-white"
: locked
? "border border-slate-200 text-slate-300 dark:border-slate-700 dark:text-slate-600"
: "border-2 border-slate-400 text-slate-500 dark:border-muted-foreground dark:text-muted-foreground"
}`}
>
{index}
</span>
<span>{label}</span>
{locked && <span className="ml-0.5 text-[9px]">锁</span>}
{active && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-8 h-1 bg-primary rounded-full" />
)}
</button>
);
}
@@ -0,0 +1,465 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import {
CheckCircle,
Database,
FileText,
Loader2,
Paperclip,
Pause,
RefreshCw,
SendHorizontal,
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useLocalSettings } from "@/core/settings";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
export function AgentProgressPanel({
phase,
dialogues,
error,
busy,
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
onPause,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
mdAvailable: boolean;
/** 后端流程图抽取阶段(Phase 1)。 */
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
pendingClarification: string | null;
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
ingestAvailable: boolean;
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
pendingIngestClarification: string | null;
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
pendingFlowForIngest: boolean;
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
onManualIngest: UseStep3SummaryReturn["runIngest"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
setAskFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
};
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = askText.trim();
const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
setAskText("");
setAskFiles([]);
onAsk(text, hasFiles ? askFiles : undefined);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
</div>
</div>
<select
id="step3-summary-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化总结线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">总结失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
{(flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
</div>
)}
{flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{ingestAvailable && ingestStatus === "asking" && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
流程图已生成,是否进行数据入库?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "now", t: "现在入库" },
{ k: "later", t: "稍后入库" },
{ k: "never", t: "不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestChoice(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */}
{ingestAvailable && pendingFlowForIngest && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "generate", t: "生成流程图" },
{ k: "cancel", t: "暂不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveFlowForIngest(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
没太理解你的入库意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "start_ingest", t: "开始入库" },
{ k: "add_data", t: "补充内容后再入库" },
{ k: "other", t: "只是提问 / 改报告" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestClarification(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{askFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => removeFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={askText}
onChange={(e) => setAskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={1}
placeholder={
busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
{ingestAvailable &&
ingestStatus !== "asking" &&
ingestStatus !== "running" &&
ingestStatus !== "idle" && (
<button
type="button"
onClick={() => onManualIngest()}
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
数据入库
</button>
)}
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
>
<Paperclip className="h-4 w-4" />
</button>
{/* 暂停(仅生成中显示) */}
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前生成"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
{/* 发送(随时可发:生成中发送=打断并续问) */}
<button
type="submit"
disabled={askText.trim().length === 0 && askFiles.length === 0}
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="h-4 w-4" />
</button>
</div>
</form>
</div>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
@@ -0,0 +1,342 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Download, Eye, FileText, Loader2, X } from "lucide-react";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
export interface SeatDelivery {
sender: string;
avatarType: string;
content: string;
}
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
export function SourceCard({
icon,
title,
hint,
badge,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
hint: string;
badge?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
{badge && (
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
{badge}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
</button>
);
}
export function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
);
}
export function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
// 经 portal 挂到 body:页面 <main> 是 `relative z-0`(浅蓝主题层叠基线),会把
// 内部 fixed 弹窗困在 z-0 层叠上下文里,被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
// 挂到 body 后脱离该上下文,z-50 才真正盖在顶栏之上,弹窗标题栏正常显示。
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
onClick={onClose}
>
<div
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>,
document.body,
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
export function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(0);
if (seatDeliveries.length === 0) {
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
}
return (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const open = openIdx === idx;
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setOpenIdx(open ? null : idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{desensitize(seat.content || "(空)")}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
null,
);
useEffect(() => {
if (seatEntries.length === 0) {
setBySeat([]);
return;
}
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatEntries]);
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
}
};
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
if (typeof window !== "undefined") {
window.open(
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
"_blank",
"noopener,noreferrer",
);
}
};
return (
<>
<ContentModal title="各席位生成文件" onClose={onClose}>
{bySeat.length === 0 ? (
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{bySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
正在查询 outputs 目录…
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</ContentModal>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</>
);
}
@@ -0,0 +1,123 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type {
ClarificationPayload,
ModelSwitchInfo,
StreamPhase,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2ExecutionRefs } from "@/roundtable-planning/hooks/useStep2ExecutionRefs";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
type EngineExecutionRefName =
| "isOrchestratingRef"
| "orchestrationCancelRef"
| "orchestrationEpochRef"
| "selectedAgentsRef"
| "agentNameToRoleRef"
| "activeAbortersRef"
| "pendingInjectFilesRef"
| "selectedModelRef"
| "agentConfigsRef"
| "gatheredDeliveriesRef"
| "seatModesRef"
| "selectedModeRef"
| "seatSkillDirectivesRef"
| "orchestrationPlanRef"
| "dagExecModeRef"
| "dagPlanExecutedRef";
export interface Step2EngineRuntimePort {
refs: Pick<Step2ExecutionRefs, EngineExecutionRefName>;
resolveSeatThreadId: (ids: ThreadIdMap, agentName: string) => string | undefined;
}
export interface Step2EngineDialoguePort {
appendStreamingDialogue: (card: {
sender: string;
avatarType: string;
initialContent?: string;
confidence?: string;
agentThreadId?: string;
}) => number;
appendToDialogue: (id: number, deltaText: string) => void;
setDialogueContent: (id: number, fullText: string) => void;
appendStepIfNew: (
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => void;
setDialogueConfidence: (id: number, confidence: string) => void;
phaseLabel: (
phase: StreamPhase,
agentType: "leader" | "special",
toolName?: string,
) => string;
finalizeDialogue: (id: number, finalContent: string, confidence?: string) => void;
makeModelSwitchHandler: (
bubbleId: number,
isParallel: boolean,
) => (info: ModelSwitchInfo) => void;
}
export interface Step2EngineSpeakerPort {
highlightSpeaker: (speakerId: number | null) => void;
markSpeakerStreaming: (speakerId: number) => void;
markSpeakerIdle: (speakerId: number) => void;
markParallelSpeakers: (speakerIds: number[]) => void;
clearAllStreamingSpeakers: () => void;
}
export interface Step2EngineParallelPort {
recommendParallelRef: MutableRefObject<boolean>;
parallelStreamRef: MutableRefObject<
Map<number, { text: string; charCount: number }>
>;
ingestParallelDelta: (bubbleId: number, deltaText: string) => void;
resetParallelStreams: () => void;
setParallelBatch: Dispatch<SetStateAction<ParallelSeatView[]>>;
setSelectedParallelSeats: Dispatch<SetStateAction<string[]>>;
}
export interface Step2EngineDagPort {
setDagNodeStates: Dispatch<
SetStateAction<Record<string, ChainNodeState>>
>;
}
export interface Step2EngineArtifactPort {
handleArtifactToolPhase: (
phase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
seatThreadId: string | undefined,
) => void;
handleUpstreamFrame: (frame: unknown, seatThreadId: string | undefined) => void;
ingestSubagentTaskFrame: (frame: unknown, bubbleId: number) => void;
}
export interface Step2EngineControlPort {
setIsOrchestrating: Dispatch<SetStateAction<boolean>>;
setPendingClarification: Dispatch<SetStateAction<ClarificationPayload | null>>;
setClarificationDraft: Dispatch<SetStateAction<string>>;
setLastLeaderContent: Dispatch<SetStateAction<string>>;
setConsensusPercentage: Dispatch<SetStateAction<number>>;
setHasConsensus: Dispatch<SetStateAction<boolean>>;
triggerToast: (message: string) => void;
}
/**
* Shared boundary for Step 2 orchestration engines.
* Engines depend on grouped capabilities instead of closing over the whole page hook.
*/
export interface Step2EngineContext {
runtime: Step2EngineRuntimePort;
dialogue: Step2EngineDialoguePort;
speakers: Step2EngineSpeakerPort;
parallel: Step2EngineParallelPort;
dag: Step2EngineDagPort;
artifacts: Step2EngineArtifactPort;
control: Step2EngineControlPort;
}
@@ -0,0 +1,70 @@
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
/** Step 2 对话流里的一条气泡(总控派活 / 子智能体交付 / 系统播报 / 用户插话)。 */
export interface Step2Dialogue {
id: number;
sender: string;
avatarType: string;
content: string;
time: string;
confidence: string;
streaming?: boolean;
agentThreadId?: string;
subtaskIds?: string[];
steps?: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}>;
}
/** One seat in a parallel execution batch. */
export interface ParallelSeatView {
agentId: string;
displayName: string;
avatarType: string;
status: "running" | "done" | "error";
bubbleId: number;
}
export interface StubToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
/** AI message stub injected into the artifact sandbox ThreadContext. */
export interface StubAiMessage {
id: string;
type: "ai";
content: string;
tool_calls: StubToolCall[];
}
/** Per-seat Persona overrides. Empty model means follow the global model. */
export interface AgentConfig {
model: string;
weight: number;
prompt: string;
temp: number;
}
/** Persisted Step 2 snapshot used by draft restore and live-job takeover. */
export interface Step2Snapshot {
selectedAgents?: SelectedAgent[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: Step2Dialogue[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages: Record<string, StubAiMessage[]>;
orchestrationMode?: "recommend" | "chain" | "dag";
chain?: { id: string; title: string } | null;
orchestrationPlan?: OrchestrationPlan | null;
}
@@ -0,0 +1,376 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
import {
buildChainLeaderTask,
pickChainTask,
takeOnce,
} from "@/roundtable-planning/lib/step2-orchestration-helpers";
/**
* Chain mode: the coordinator dispatches each configured seat in order, then closes out.
* Special-agent runs intentionally stay serial to avoid concurrent token-level UI updates.
*/
export function useChainOrchestrationEngine(context: Step2EngineContext) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
},
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
control: {
setIsOrchestrating,
setPendingClarification,
setClarificationDraft,
setLastLeaderContent,
setConsensusPercentage,
setHasConsensus,
triggerToast,
},
} = context;
const {
isOrchestratingRef,
orchestrationCancelRef,
orchestrationEpochRef,
selectedAgentsRef,
agentNameToRoleRef,
activeAbortersRef,
pendingInjectFilesRef,
selectedModelRef,
agentConfigsRef,
gatheredDeliveriesRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
} = refs;
return useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
if (isStale()) {
finishedAllSeats = false;
break;
}
const seat = chainSeats[i];
const meta = agentNameToRoleRef.current[seat.agent_id];
const seatName = meta?.displayName ?? seat.name ?? seat.agent_id;
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "链条派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildChainLeaderTask({
seedMessage,
position: i,
total,
seatAgentId: seat.agent_id,
seatName,
isFirst: i === 0,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
suppressPreBroadcast: i > 0,
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(
leaderBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
} catch (error) {
activeAbortersRef.current.delete(leaderAborter);
if (
leaderAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
throw error;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) {
finalizeDialogue(
leaderBubbleId,
leaderRes.content || "(已停止)",
"已停止",
);
finishedAllSeats = false;
break;
}
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("总控需要补充信息,请回答下方问题后继续");
finishedAllSeats = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
const task = pickChainTask(
leaderRes.dispatched,
leaderRes.content,
seat.agent_id,
seatName,
);
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, seat.agent_id);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subBubbleId = appendStreamingDialogue({
sender: seatName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: seat.agent_id,
displayName: seatName,
newMessage: task,
peerDeliveries: gatheredDeliveriesRef.current,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[seat.agent_id] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[seat.agent_id] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(
subBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, subBubbleId);
},
});
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
"已停止",
);
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (error) {
if (
subAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, error);
finalizeDialogue(
subBubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) {
finishedAllSeats = false;
break;
}
setConsensusPercentage(
Math.min(95, Math.round(((i + 1) / total) * 90)),
);
}
if (!isStale() && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "汇总链条结论",
agentThreadId: leaderThreadId,
});
const closeoutAborter = new AbortController();
activeAbortersRef.current.add(closeoutAborter);
try {
const closeoutRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
synthesisMode: true,
suppressPreBroadcast: true,
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(closeoutBubbleId, label);
appendStepIfNew(
closeoutBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
finalizeDialogue(closeoutBubbleId, closeoutRes.content, "全案盖印");
if (closeoutRes.content) setLastLeaderContent(closeoutRes.content);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("业务链条研讨完成!已生成全案结论");
} catch (error) {
if (
closeoutAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(closeoutBubbleId, "(已停止)", "已停止");
} else {
console.error("chain closeout error:", error);
finalizeDialogue(
closeoutBubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
}
}
} catch (error) {
console.error("chain orchestration error:", error);
triggerToast(`链条研讨异常: ${(error as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
clearAllStreamingSpeakers,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
highlightSpeaker,
ingestSubagentTaskFrame,
isOrchestratingRef,
makeModelSwitchHandler,
markSpeakerIdle,
markSpeakerStreaming,
orchestrationCancelRef,
orchestrationEpochRef,
pendingInjectFilesRef,
phaseLabel,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedAgentsRef,
selectedModeRef,
selectedModelRef,
setClarificationDraft,
setConsensusPercentage,
setDialogueConfidence,
setHasConsensus,
setIsOrchestrating,
setLastLeaderContent,
setPendingClarification,
triggerToast,
],
);
}
@@ -0,0 +1,485 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import {
buildDagStageLeaderTask,
pickDagSeatTask,
} from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
import { takeOnce } from "@/roundtable-planning/lib/step2-orchestration-helpers";
export interface DagOrchestrationEngineContext extends Step2EngineContext {
continueWithRecommend: (
seedMessage: string,
ids: ThreadIdMap,
coordinator: string,
) => Promise<void>;
}
/**
* DAG mode: stages execute serially, seats inside a stage execute in parallel, and the
* recommend engine takes over after the plan when final synthesis is enabled.
*/
export function useDagOrchestrationEngine(
context: DagOrchestrationEngineContext,
) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
highlightSpeaker,
markSpeakerIdle,
markParallelSpeakers,
clearAllStreamingSpeakers,
},
parallel: {
parallelStreamRef,
ingestParallelDelta,
resetParallelStreams,
setParallelBatch,
setSelectedParallelSeats,
},
dag: { setDagNodeStates },
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
control: {
setIsOrchestrating,
setPendingClarification,
setClarificationDraft,
setLastLeaderContent,
setConsensusPercentage,
setHasConsensus,
triggerToast,
},
continueWithRecommend,
} = context;
const {
orchestrationPlanRef,
isOrchestratingRef,
orchestrationCancelRef,
orchestrationEpochRef,
dagPlanExecutedRef,
gatheredDeliveriesRef,
agentNameToRoleRef,
activeAbortersRef,
pendingInjectFilesRef,
selectedModelRef,
dagExecModeRef,
agentConfigsRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
} = refs;
return useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
const plan = orchestrationPlanRef.current;
if (!plan || plan.stages.length === 0) {
triggerToast("没有可执行的 DAG 编排计划");
return;
}
if (isOrchestratingRef.current) return;
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
dagPlanExecutedRef.current = true;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllStages = true;
const priorDeliveries: Array<{ name: string; content: string }> = [
...gatheredDeliveriesRef.current,
];
const markDagNode = (agentId: string, state: ChainNodeState) =>
setDagNodeStates((previous) => ({
...previous,
[agentId.toLowerCase()]: state,
}));
setDagNodeStates(
Object.fromEntries(
plan.stages.flatMap((stage) =>
stage.agentIds.map((agentId) => [
agentId.toLowerCase(),
"pending" as ChainNodeState,
]),
),
),
);
try {
for (let stageIndex = 0; stageIndex < plan.stages.length; stageIndex += 1) {
if (isStale()) {
finishedAllStages = false;
break;
}
const stage = plan.stages[stageIndex];
const isParallel = stage.agentIds.length > 1;
const stageSeats = stage.agentIds.map((agentId) => {
const meta = agentNameToRoleRef.current[agentId.toLowerCase()];
return { agentId, meta, name: meta?.displayName ?? agentId };
});
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: stageIndex === 0 ? "派活中" : "承上派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildDagStageLeaderTask({
seedMessage,
stageIndex,
totalStages: plan.stages.length,
seats: stageSeats.map((seat) => ({
agentId: seat.agentId,
name: seat.name,
})),
isFirst: stageIndex === 0,
goal: stage.goal,
coordinatorPrompt: plan.coordinatorPrompt,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderResponse;
try {
leaderResponse = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
synthesisMode: false,
suppressPreBroadcast: true,
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(
leaderBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, leaderThreadId),
});
} catch (error) {
activeAbortersRef.current.delete(leaderAborter);
if (
leaderAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
finishedAllStages = false;
break;
}
throw error;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) {
finalizeDialogue(
leaderBubbleId,
leaderResponse.content || "(已停止)",
"已停止",
);
finishedAllStages = false;
break;
}
if (leaderResponse.clarification) {
finalizeDialogue(
leaderBubbleId,
leaderResponse.content,
"等待用户澄清",
);
setPendingClarification(leaderResponse.clarification);
setClarificationDraft("");
triggerToast("总控需要补充信息,请回答下方问题后继续");
finishedAllStages = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderResponse.content, "派活说明");
if (leaderResponse.content) {
setLastLeaderContent(leaderResponse.content);
}
const seatPrep = stageSeats.map(({ agentId, meta, name }) => {
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, agentId);
const bubbleId = appendStreamingDialogue({
sender: name,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return {
agentId,
meta,
seatName: name,
avatarType,
seatThreadId,
bubbleId,
};
});
markParallelSpeakers(
seatPrep
.map((seat) => seat.meta?.speakerId)
.filter((value): value is number => typeof value === "number"),
);
for (const seat of seatPrep) {
markDagNode(seat.agentId, "active");
}
if (isParallel) {
setParallelBatch(
seatPrep.map((seat) => ({
agentId: seat.agentId,
displayName: seat.seatName,
avatarType: seat.avatarType,
status: "running" as const,
bubbleId: seat.bubbleId,
})),
);
const columns = dagExecModeRef.current === "sandbox" ? 1 : 2;
setSelectedParallelSeats(
seatPrep.slice(0, columns).map((seat) => seat.agentId),
);
}
const markBatchStatus = (
agentId: string,
status: ParallelSeatView["status"],
) =>
setParallelBatch((previous) =>
previous.map((seat) =>
seat.agentId === agentId ? { ...seat, status } : seat,
),
);
const stageResults = await Promise.all(
seatPrep.map(
async ({
agentId,
meta,
seatName,
seatThreadId,
bubbleId,
}): Promise<{ name: string; content: string } | null> => {
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const task = pickDagSeatTask(
leaderResponse.dispatched,
agentId,
seatName,
seedMessage,
priorDeliveries.length > 0,
dagExecModeRef.current === "sandbox",
stage.goal,
);
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const response = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: agentId,
displayName: seatName,
newMessage: task,
parallelNoFile: dagExecModeRef.current !== "sandbox",
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[agentId] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) =>
isParallel
? ingestParallelDelta(bubbleId, text)
: appendToDialogue(bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(
bubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
seatThreadId,
);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
if (isStale()) {
finalizeDialogue(
bubbleId,
response.content || "(已停止)",
"已停止",
);
markBatchStatus(agentId, "done");
return null;
}
finalizeDialogue(bubbleId, response.content, "子智能体交付");
markBatchStatus(agentId, "done");
return response.content?.trim()
? { name: seatName, content: response.content }
: null;
} catch (error) {
if (
subAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(bubbleId, "(已停止)", "已停止");
markBatchStatus(agentId, "done");
} else {
console.error(`[dag-special:${agentId}] error:`, error);
finalizeDialogue(
bubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
markBatchStatus(agentId, "error");
}
return null;
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
markDagNode(agentId, "done");
parallelStreamRef.current.delete(bubbleId);
}
},
),
);
if (isParallel) {
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
}
if (isStale()) {
finishedAllStages = false;
break;
}
for (const result of stageResults) {
if (result?.content.trim()) priorDeliveries.push(result);
}
setConsensusPercentage(
Math.min(
95,
Math.round(((stageIndex + 1) / plan.stages.length) * 90),
),
);
}
if (!isStale() && finishedAllStages && plan.finalSynthesis) {
highlightSpeaker(null);
isOrchestratingRef.current = false;
setIsOrchestrating(false);
await continueWithRecommend(
"各阶段席位已按依赖编排完成交付。请你作为总控,综合各席位的交付:若仍需补充则继续派活,否则直接输出最终方案并收口。",
ids,
coord,
);
return;
}
if (!isStale() && finishedAllStages) {
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("依赖编排各阶段已全部完成");
}
} catch (error) {
console.error("dag orchestration error:", error);
triggerToast(`依赖编排研讨异常: ${(error as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
setIsOrchestrating(false);
}
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
clearAllStreamingSpeakers,
continueWithRecommend,
dagExecModeRef,
dagPlanExecutedRef,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
highlightSpeaker,
ingestParallelDelta,
ingestSubagentTaskFrame,
isOrchestratingRef,
makeModelSwitchHandler,
markParallelSpeakers,
markSpeakerIdle,
orchestrationCancelRef,
orchestrationEpochRef,
orchestrationPlanRef,
parallelStreamRef,
pendingInjectFilesRef,
phaseLabel,
resetParallelStreams,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedModeRef,
selectedModelRef,
setClarificationDraft,
setConsensusPercentage,
setDagNodeStates,
setDialogueConfidence,
setHasConsensus,
setIsOrchestrating,
setLastLeaderContent,
setParallelBatch,
setPendingClarification,
setSelectedParallelSeats,
triggerToast,
],
);
}
@@ -0,0 +1,215 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
const GATHER_TASK =
"【资料收集阶段——只取数、不研讨】\n现在是正式研讨**之前**的并行资料收集阶段。请你**只负责把与本次任务、" +
"与你这个席位职责相关的真实信息/数据收集、整理出来**(优先用你配置的技能去检索/获取真实信息,没有技能可凭专业知识梳理)," +
"为后续正式研讨打底。\n**这一步不要做分析评估、不要给方案、不要下结论**——把判断与方案留到研讨阶段。" +
"请把你收集到的关键信息整理成条理清晰的事实/要点清单输出,**不要向用户提问或等待确认**。";
/** Runs the optional pre-discussion parallel data-gathering phase once per round. */
export function useGatherPhaseEngine(context: Step2EngineContext) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
markSpeakerIdle,
markParallelSpeakers,
},
parallel: {
parallelStreamRef,
ingestParallelDelta,
resetParallelStreams,
setParallelBatch,
setSelectedParallelSeats,
},
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
} = context;
const {
selectedAgentsRef,
agentNameToRoleRef,
agentConfigsRef,
activeAbortersRef,
selectedModelRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
gatheredDeliveriesRef,
} = refs;
return useCallback(
async (ids: ThreadIdMap): Promise<void> => {
const agents = selectedAgentsRef.current;
if (agents.length === 0) return;
const seatPrep = agents.map((agent) => {
const meta = agentNameToRoleRef.current[agent.agent_id.toLowerCase()];
const seatName = meta?.displayName ?? agent.name;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, agent.agent_id);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "并行取数中",
agentThreadId: seatThreadId,
});
return {
agentId: agent.agent_id,
seatName,
avatarType,
seatThreadId,
bubbleId,
meta,
};
});
markParallelSpeakers(
seatPrep
.map((seat) => seat.meta?.speakerId)
.filter((value): value is number => typeof value === "number"),
);
setParallelBatch(
seatPrep.map((seat) => ({
agentId: seat.agentId,
displayName: seat.seatName,
avatarType: seat.avatarType,
status: "running" as const,
bubbleId: seat.bubbleId,
})),
);
setSelectedParallelSeats(
seatPrep.slice(0, 1).map((seat) => seat.agentId),
);
const markBatchStatus = (
agentId: string,
status: ParallelSeatView["status"],
) =>
setParallelBatch((previous) =>
previous.map((seat) =>
seat.agentId === agentId ? { ...seat, status } : seat,
),
);
const results = await Promise.all(
seatPrep.map(
async ({
agentId,
seatName,
seatThreadId,
bubbleId,
meta,
}) => {
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const response = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: agentId,
displayName: seatName,
newMessage: GATHER_TASK,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[agentId] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, true),
onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(
bubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
finalizeDialogue(bubbleId, response.content, "资料收集");
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return response.content?.trim()
? { name: seatName, content: response.content }
: null;
} catch (error) {
activeAbortersRef.current.delete(subAborter);
finalizeDialogue(
bubbleId,
`(取数失败) ${(error as Error).message}`,
"取数失败",
);
markBatchStatus(agentId, "done");
parallelStreamRef.current.delete(bubbleId);
return null;
}
},
),
);
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
gatheredDeliveriesRef.current = results.filter(
(result): result is { name: string; content: string } => Boolean(result),
);
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestParallelDelta,
ingestSubagentTaskFrame,
makeModelSwitchHandler,
markParallelSpeakers,
markSpeakerIdle,
parallelStreamRef,
phaseLabel,
resetParallelStreams,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedAgentsRef,
selectedModeRef,
selectedModelRef,
setDialogueConfidence,
setParallelBatch,
setSelectedParallelSeats,
],
);
}
@@ -0,0 +1,487 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type LeaderDispatch,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import { MAX_CYCLES } from "@/roundtable-planning/lib/roundtable-constants";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
import { takeOnce } from "@/roundtable-planning/lib/step2-orchestration-helpers";
const PARALLEL_JUDGE_NOTE =
"\n\n【派活方式】派活前请先判断各席位子任务之间的依赖关系:相互独立的子任务请在" +
"**同一轮**通过 agent_orchestration 一次性派给多个席位(系统会并行执行、显著提速);" +
"有先后依赖的子任务则每轮只派一个席位,等其交付后再派下一个。";
const SEAT_DELIVERY_GUIDE =
"\n\n【交付要求】请直接基于已知信息做出合理假设、给出完整交付,**不要向用户提问或等待确认**;" +
"承接此前各席位的交付(需要其它席位产物全文时用 read_peer_delivery 获取),不要重复或推翻已有结论。" +
"交付请按以下结构组织:① 一句话核心结论(先行);② 关键要点(分条列出);" +
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
/**
* Recommend mode: the coordinator freely decides which seats to dispatch and whether a
* round can run in parallel. It stops on consensus or at the MAX_CYCLES safety limit.
*/
export function useRecommendOrchestrationEngine(context: Step2EngineContext) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendToDialogue,
setDialogueContent,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
markParallelSpeakers,
clearAllStreamingSpeakers,
},
parallel: {
recommendParallelRef,
parallelStreamRef,
ingestParallelDelta,
resetParallelStreams,
setParallelBatch,
setSelectedParallelSeats,
},
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
control: {
setIsOrchestrating,
setPendingClarification,
setClarificationDraft,
setLastLeaderContent,
setConsensusPercentage,
setHasConsensus,
triggerToast,
},
} = context;
const {
isOrchestratingRef,
orchestrationCancelRef,
orchestrationEpochRef,
agentNameToRoleRef,
activeAbortersRef,
pendingInjectFilesRef,
selectedModelRef,
agentConfigsRef,
gatheredDeliveriesRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
} = refs;
return useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const priorDeliveries: Array<{ name: string; content: string }> = [
...gatheredDeliveriesRef.current,
];
const runSeatDelivery = async (input: {
agentId: string;
displayName: string;
task: string;
bubbleId: number;
seatThreadId: string | undefined;
isParallel: boolean;
}): Promise<{ content: string; aborted: boolean }> => {
const meta = agentNameToRoleRef.current[input.agentId.toLowerCase()];
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const fullTask = input.task + SEAT_DELIVERY_GUIDE;
for (let attempt = 0; attempt < 2; attempt += 1) {
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const response = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: input.agentId,
displayName: input.displayName,
newMessage: fullTask,
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[input.agentId] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[input.agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(
input.bubbleId,
input.isParallel,
),
onTextDelta: (text) =>
input.isParallel
? ingestParallelDelta(input.bubbleId, text)
: appendToDialogue(input.bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(input.bubbleId, label);
appendStepIfNew(
input.bubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
input.seatThreadId,
);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, input.seatThreadId);
ingestSubagentTaskFrame(frame, input.bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
if (isStale()) {
finalizeDialogue(
input.bubbleId,
response.content || "(已停止)",
"已停止",
);
return { content: "", aborted: true };
}
finalizeDialogue(input.bubbleId, response.content, "子智能体交付");
return { content: response.content ?? "", aborted: false };
} catch (error) {
activeAbortersRef.current.delete(subAborter);
if (
subAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(input.bubbleId, "(已停止)", "已停止");
return { content: "", aborted: true };
}
if (attempt === 0) {
console.warn(
`[special:${input.agentId}] 第一次失败,自动重试:`,
error,
);
if (input.isParallel) {
parallelStreamRef.current.delete(input.bubbleId);
} else {
setDialogueContent(input.bubbleId, "");
}
setDialogueConfidence(input.bubbleId, "失败重试中…");
continue;
}
console.error(`[special:${input.agentId}] error:`, error);
finalizeDialogue(
input.bubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
return { content: "", aborted: false };
}
}
return { content: "", aborted: false };
};
let nextLeaderTask = seedMessage + PARALLEL_JUDGE_NOTE;
let safetyCounter = 0;
try {
while (!isStale() && safetyCounter < MAX_CYCLES) {
safetyCounter += 1;
highlightSpeaker(null);
const leaderThreadId = resolveSeatThreadId(ids, coord);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: leaderThreadId,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderResponse;
try {
leaderResponse = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
synthesisMode: safetyCounter > 1,
suppressPreBroadcast: safetyCounter > 1,
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(
leaderBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, leaderThreadId),
});
} catch (error) {
activeAbortersRef.current.delete(leaderAborter);
if (
leaderAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
break;
}
throw error;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) break;
if (leaderResponse.clarification) {
finalizeDialogue(
leaderBubbleId,
leaderResponse.content,
"等待用户澄清",
);
highlightSpeaker(null);
setPendingClarification(leaderResponse.clarification);
setClarificationDraft("");
triggerToast("总控需要补充信息,请回答下方问题后继续");
break;
}
finalizeDialogue(
leaderBubbleId,
leaderResponse.content,
leaderResponse.dispatched.length === 0 ? "全案盖印" : "派活说明",
);
if (leaderResponse.content) {
setLastLeaderContent(leaderResponse.content);
}
if (leaderResponse.dispatched.length === 0) {
highlightSpeaker(null);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("圆桌研讨论证会圆满落幕!已取得 100% 专家组共识");
break;
}
const dispatched = leaderResponse.dispatched as LeaderDispatch[];
if (dispatched.length > 1) {
recommendParallelRef.current = true;
const seatPrep = dispatched.map(([subName, task]) => {
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
const seatName = meta?.displayName ?? subName;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, subName);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return {
agentId: subName,
task,
meta,
seatName,
avatarType,
seatThreadId,
bubbleId,
};
});
markParallelSpeakers(
seatPrep
.map((seat) => seat.meta?.speakerId)
.filter((value): value is number => typeof value === "number"),
);
setParallelBatch(
seatPrep.map((seat) => ({
agentId: seat.agentId,
displayName: seat.seatName,
avatarType: seat.avatarType,
status: "running" as const,
bubbleId: seat.bubbleId,
})),
);
setSelectedParallelSeats(
seatPrep.slice(0, 1).map((seat) => seat.agentId),
);
const markBatchStatus = (
agentId: string,
status: ParallelSeatView["status"],
) =>
setParallelBatch((previous) =>
previous.map((seat) =>
seat.agentId === agentId ? { ...seat, status } : seat,
),
);
const batchResults = await Promise.all(
seatPrep.map(
async ({
agentId,
task,
meta,
seatName,
seatThreadId,
bubbleId,
}) => {
const result = await runSeatDelivery({
agentId,
displayName: seatName,
task,
bubbleId,
seatThreadId,
isParallel: true,
});
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return result.content.trim()
? { name: seatName, content: result.content }
: null;
},
),
);
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
recommendParallelRef.current = false;
for (const result of batchResults) {
if (result) priorDeliveries.push(result);
}
} else {
for (const [subName, task] of dispatched) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const result = await runSeatDelivery({
agentId: subName,
displayName: meta?.displayName ?? subName,
task,
bubbleId: subBubbleId,
seatThreadId,
isParallel: false,
});
if (meta) markSpeakerIdle(meta.speakerId);
if (result.content.trim()) {
priorDeliveries.push({
name: meta?.displayName ?? subName,
content: result.content,
});
}
if (result.aborted) break;
}
}
if (isStale()) break;
setConsensusPercentage((previous) =>
Math.min(95, parseFloat((previous + 8).toFixed(1))),
);
nextLeaderTask =
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
PARALLEL_JUDGE_NOTE;
}
if (safetyCounter >= MAX_CYCLES) {
triggerToast("⏱️ 已达到最大研讨轮次,自动停止以避免循环");
}
} catch (error) {
console.error("orchestration error:", error);
triggerToast(`研讨异常: ${(error as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
clearAllStreamingSpeakers,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
highlightSpeaker,
ingestParallelDelta,
ingestSubagentTaskFrame,
isOrchestratingRef,
makeModelSwitchHandler,
markParallelSpeakers,
markSpeakerIdle,
markSpeakerStreaming,
orchestrationCancelRef,
orchestrationEpochRef,
parallelStreamRef,
pendingInjectFilesRef,
phaseLabel,
recommendParallelRef,
resetParallelStreams,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedModeRef,
selectedModelRef,
setClarificationDraft,
setConsensusPercentage,
setDialogueConfidence,
setDialogueContent,
setHasConsensus,
setIsOrchestrating,
setLastLeaderContent,
setParallelBatch,
setPendingClarification,
setSelectedParallelSeats,
triggerToast,
],
);
}
@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { isWujieEnv, readWujieProps } from "@/core/embed/wujie";
export interface RoundtablePrefill {
taskTitle?: string;
taskGoal?: string;
chainId?: string;
businessCode?: string;
taskId?: string;
actionId?: string;
actionName?: string;
autoStart?: boolean;
}
const ROUNDTABLE_DEEPLINK_PREFILL_KEY = "roundtable:deeplink-prefill";
/** xdfx-A 按行动 id 隔离记录,其它业务按任务 id 隔离。 */
export function recordTaskIdOf(prefill: RoundtablePrefill | null): string | null {
return prefill?.actionId ?? prefill?.taskId ?? null;
}
function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
if (typeof window === "undefined") return null;
const hash = window.location.hash || "";
if (!hash.includes("/embed/roundtable")) return null;
const queryIndex = hash.indexOf("?");
if (queryIndex < 0) return null;
const params = new URLSearchParams(hash.slice(queryIndex + 1));
const taskTitle = params.get("taskTitle") ?? undefined;
const taskGoal = params.get("taskGoal") ?? undefined;
const chainId = params.get("chainId") ?? undefined;
const businessCode = params.get("businessCode") ?? undefined;
const taskId = params.get("taskId") ?? undefined;
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) return null;
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
}
function readWujiePrefill(): RoundtablePrefill | null {
if (!isWujieEnv()) return null;
const props = readWujieProps();
const taskTitle = typeof props.taskTitle === "string" ? props.taskTitle : undefined;
const taskGoal = typeof props.taskGoal === "string" ? props.taskGoal : undefined;
const chainId = typeof props.chainId === "string" ? props.chainId : undefined;
const businessCode =
typeof props.businessCode === "string" ? props.businessCode : undefined;
const taskId = typeof props.taskId === "string" ? props.taskId : undefined;
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) {
return readEmbedHashQueryPrefill();
}
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
}
function readPersistedDeeplinkPrefill(path: string): RoundtablePrefill | null {
try {
const raw = sessionStorage.getItem(ROUNDTABLE_DEEPLINK_PREFILL_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { path?: string; prefill?: RoundtablePrefill };
if (!parsed.prefill || parsed.path !== path) return null;
return parsed.prefill;
} catch {
return null;
}
}
function persistDeeplinkPrefill(path: string, prefill: RoundtablePrefill): void {
try {
sessionStorage.setItem(
ROUNDTABLE_DEEPLINK_PREFILL_KEY,
JSON.stringify({ path, prefill }),
);
} catch {
// sessionStorage can be unavailable in privacy mode or when quota is exhausted.
}
}
/**
* Resolves the one-shot roundtable entry context and persists task deep links for refreshes.
* Priority: router navigation state → Wujie props/hash → session storage.
*/
export function useRoundtablePrefill() {
const location = useLocation();
const inWujieEmbed = isWujieEnv();
const prefillRef = useRef<RoundtablePrefill | null>(
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)
?.roundtablePrefill ??
readWujiePrefill() ??
readPersistedDeeplinkPrefill(location.pathname)),
);
const consumedRef = useRef(false);
useEffect(() => {
if (consumedRef.current || !prefillRef.current) return;
consumedRef.current = true;
if (prefillRef.current.taskId) {
persistDeeplinkPrefill(location.pathname, prefillRef.current);
}
try {
window.history.replaceState({ ...window.history.state, usr: null }, "");
} catch {
// History state cleanup is best-effort in embedded browsers.
}
}, [location.pathname]);
return { prefill: prefillRef.current, inWujieEmbed };
}
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { useStep3Report, type ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
const STEP3_FLOW_STAGGER_MS = 300;
export interface Step3ConversationSnapshot {
dialogues: unknown[];
seatStubMessages: Record<string, unknown[]>;
ingestStatus?: string;
}
export interface UseRoundtableStep3FlowsOptions {
intentReady: ReportIntent | null;
step2RoundtableDialogues: Step2Dialogue[];
consensusText: string;
selectedModel: string;
orchestrationPlan: OrchestrationPlan | null;
step3Report: DraftStep3Snapshot | null;
onReportSaved: (snapshot: DraftStep3Snapshot) => void;
onSummarySaved: (snapshot: NonNullable<DraftStep3Snapshot["summaryReport"]>) => void;
ingestAvailable: boolean;
ingestSkill: string;
ingestBusinessCode: string | null;
taskId: string | null;
chainTitle: string | null;
isDark: boolean;
currentStep: number;
draftId: string | null;
summaryConvoRef: MutableRefObject<Step3ConversationSnapshot>;
reportConvoRef: MutableRefObject<Step3ConversationSnapshot>;
dashboardConvoRef: MutableRefObject<Step3ConversationSnapshot>;
}
export function useRoundtableStep3Flows({
intentReady,
step2RoundtableDialogues,
consensusText,
selectedModel,
orchestrationPlan,
step3Report,
onReportSaved: handleReportSaved,
onSummarySaved: handleSummarySaved,
ingestAvailable,
ingestSkill,
ingestBusinessCode,
taskId,
chainTitle,
isDark,
currentStep,
draftId,
summaryConvoRef: step3SummaryConvoRef,
reportConvoRef: step3ReportConvoRef,
dashboardConvoRef: step3DashConvoRef,
}: UseRoundtableStep3FlowsOptions) {
// ── Step3 三个生成流提升到页面层(2026-06-13)────────────────────────────
// 总结报告 / 结果绘制(经典) / 大屏多页的流式 run **不随 Step3 子视图卸载而中断**:
// 用户切回第二步、在总结/绘制/最终方案视图间往返,流都在页面层继续;
// 回头进入对应视图即可续看进行中的过程或最终结果。
const step3SeatDeliveries = useMemo(
() =>
step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
content: stripReasoning(String(d.content ?? "")),
})),
[step2RoundtableDialogues],
);
// 业务链条层级(取 stages 的「本阶段目标」goal,有序)——taskId 深链模式自动结构化输出时
// 作流程图层级骨架(任务 → 层级1 → …)。memo 稳定数组身份,避免 run useCallback 频繁失效。
const chainLevels = useMemo(
() =>
(orchestrationPlan?.stages ?? [])
.map((s) => s.goal?.trim() ?? "")
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
const autoSummaryFlow = true;
const summaryFlow = useStep3Summary({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
// 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。
taskId: taskId,
// 入库场景线索:适用性判定选中的技能 + 业务码/链名,入库时引导智能体挑对入库技能。
ingestSkill,
ingestBusinessCode,
ingestChainName: chainTitle,
});
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。
step3SummaryConvoRef.current = {
dialogues: summaryFlow.dialogues,
seatStubMessages: summaryFlow.seatStubMessages,
ingestStatus: summaryFlow.ingestStatus,
};
const reportFlow = useStep3Report({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像经典看板的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3ReportConvoRef.current = {
dialogues: reportFlow.dialogues,
seatStubMessages: reportFlow.seatStubMessages,
};
const dashboardFlow = useStep3Dashboard({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
themeMode: isDark ? "dark" : "light",
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像大屏的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3DashConvoRef.current = {
dialogues: dashboardFlow.dialogues,
seatStubMessages: dashboardFlow.seatStubMessages,
};
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
const step3EnteredRef = useRef(false);
// 进第三步「错峰起跑」定时器。三流虽互无依赖、要并行展示进度,但若**同一刻**一起
// 调 start()(各自 init thread + run/stream),会把单 worker 网关 + 单连接 sqlite
// checkpoint 锁的瞬时压力顶满,回环 /runs/stream 概率性返回非 200 → 前端冒 502。
// 这里把后两个流的**起跑**各往后挪一点点(仅错开 start_run 的写尖峰,不是串行等前一个
// 跑完),削峰即可。定时器存 ref,切草稿 / 重新 hydrate / 新建任务时统一清掉,避免迟发的
// start() 打到已被复位/中断的上下文。
const step3StartTimersRef = useRef<number[]>([]);
const clearStep3StartTimers = useCallback(() => {
for (const t of step3StartTimersRef.current) {
if (typeof window !== "undefined") window.clearTimeout(t);
}
step3StartTimersRef.current = [];
}, []);
// step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。
const step3ReportRef = useRef(step3Report);
step3ReportRef.current = step3Report;
// 切草稿即在**渲染期**同步复位入口闸门:保证随后的 hydrate effect 能重新跑一次
// (此前闸门只在 currentStep 变化时复位,切历史记录若仍停在第三步 currentStep 不变,
// 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。
const prevDraftIdRef = useRef(draftId);
if (prevDraftIdRef.current !== draftId) {
const wasRealDraft = prevDraftIdRef.current != null;
prevDraftIdRef.current = draftId;
// 仅「真正切到另一份草稿」才复位入口闸门重新 hydrate。草稿**首次创建**(null→id,
// 多由本会话第一次自动保存触发)不算切换——若这时复位,正在并行跑的 Step3 三流(尤其
// 大屏)会被下方 hydrate 当成「未启动」重新 start(),既丢了在途产物又反复重画。
if (wasRealDraft) step3EnteredRef.current = false;
}
// 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
// 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。
const hydrateStep3Flows = useCallback(() => {
step3EnteredRef.current = true;
// 重新编排前先清掉上一轮可能还没触发的错峰定时器(切草稿重 hydrate 时)。
clearStep3StartTimers();
const rep = step3ReportRef.current;
// 错峰起跑助手:把一个尚未起跑的流延迟 delayMs 后再 start();触发前再校验
// step3EnteredRef(被新建任务 / 切草稿复位为 false 则跳过),避免迟发的 start()
// 打到已被中断的上下文。delay=0 表示立即(同步)起跑。
const staggerStart = (start: () => void, delayMs: number) => {
if (delayMs <= 0 || typeof window === "undefined") {
start();
return;
}
const id = window.setTimeout(() => {
if (!step3EnteredRef.current) return;
start();
}, delayMs);
step3StartTimersRef.current.push(id);
};
// ① 方案总结报告(主页,立即起跑)
const savedSum = rep?.summaryReport;
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
summaryFlow.hydrateSaved(savedSum);
} else {
summaryFlow.start();
}
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
if (rep?.html && !isDashboardHtml(rep.html)) {
reportFlow.hydrateSaved(rep);
} else {
staggerStart(() => reportFlow.start(), STEP3_FLOW_STAGGER_MS); // 仍并行绘制,只是起跑错开
}
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
const dashSaved = {
dialogues: rep?.dashboardDialogues,
seatStubMessages: rep?.dashboardSeatStubMessages,
};
if (rep?.dashboardJson) {
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
} else if (rep?.html && isDashboardHtml(rep.html)) {
dashboardFlow.hydrateSaved(rep.html, dashSaved);
} else {
staggerStart(() => dashboardFlow.start(), STEP3_FLOW_STAGGER_MS * 2);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]);
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
// **仅在真正切到另一份草稿时中断**——草稿首次创建(null→id)不是切换,那时三流可能正
// 跑着(尤其大屏绘制),误 abort 会让产物没存上、随后还被重新 hydrate 重画。
const abortPrevDraftIdRef = useRef(draftId);
useEffect(() => {
const prev = abortPrevDraftIdRef.current;
abortPrevDraftIdRef.current = draftId;
if (prev && draftId && prev !== draftId) {
clearStep3StartTimers(); // 切草稿:取消尚未触发的错峰 start,防打到新草稿上下文
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId]);
// 进第三步 / 切草稿后仍停留第三步:按当前草稿快照恢复或新建三流(闸门已在渲染期复位)。
useEffect(() => {
if (currentStep !== 3 || step3EnteredRef.current) return;
hydrateStep3Flows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, draftId]);
const reset = useCallback(() => {
clearStep3StartTimers();
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
step3EnteredRef.current = false;
}, [clearStep3StartTimers, summaryFlow, reportFlow, dashboardFlow]);
return {
summaryFlow,
reportFlow,
dashboardFlow,
reset,
};
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import type { TaskBusiness } from "@/core/auth/task-buttons";
import {
derivePurposeLevels,
fetchTaskPurposeDetail,
type PurposeLevel,
} from "@/core/auth/task-purpose";
import {
recordTaskIdOf,
type RoundtablePrefill,
} from "@/roundtable-planning/hooks/useRoundtablePrefill";
const BUSINESS_CODE_TO_TASK_BUSINESS: Readonly<Record<string, TaskBusiness>> = {
"3Q": "3qfx",
"6BF": "rwfx",
"7BF": "xdfx",
};
export interface RoundtableTaskContext {
taskId: string | null;
recordTaskId: string | null;
actionId: string | null;
businessCode: string | null;
taskBusiness: TaskBusiness | null;
purposeLevels: PurposeLevel[];
}
/**
* Derives task-scoped roundtable identifiers and loads the purpose hierarchy used by Step 1.
* xdfx records are partitioned by action id, while other businesses use the task id.
*/
export function useRoundtableTaskContext(
prefill: RoundtablePrefill | null,
): RoundtableTaskContext {
const taskId = prefill?.taskId ?? null;
const recordTaskId = recordTaskIdOf(prefill);
const actionId = prefill?.actionId ?? null;
const businessCode = prefill?.businessCode ?? null;
const taskBusiness = businessCode
? (BUSINESS_CODE_TO_TASK_BUSINESS[businessCode] ?? null)
: null;
const [purposeLevels, setPurposeLevels] = useState<PurposeLevel[]>([]);
useEffect(() => {
if (!taskId) {
setPurposeLevels([]);
return;
}
let cancelled = false;
void fetchTaskPurposeDetail(taskId)
.then((response) => {
if (!cancelled) setPurposeLevels(derivePurposeLevels(response.tree));
})
.catch(() => {
if (!cancelled) setPurposeLevels([]);
});
return () => {
cancelled = true;
};
}, [taskId]);
return {
taskId,
recordTaskId,
actionId,
businessCode,
taskBusiness,
purposeLevels,
};
}
@@ -0,0 +1,507 @@
import {
useCallback,
useEffect,
useRef,
type MutableRefObject,
} from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type {
StreamPhase,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2ExecutionRefs } from "@/roundtable-planning/hooks/useStep2ExecutionRefs";
import { useStep2ArtifactState } from "@/roundtable-planning/hooks/useStep2StateDomains";
import type {
ParallelSeatView,
Step2Dialogue,
StubAiMessage,
StubToolCall,
} from "@/roundtable-planning/hooks/step2-types";
type ArtifactExecutionRefs = Pick<
Step2ExecutionRefs,
| "orchestrationModeRef"
| "dagExecModeRef"
| "sandboxFollowThreadIdRef"
| "threadIdsRef"
| "lastReconciledSeatRef"
>;
export interface UseStep2ArtifactBridgeOptions {
selectedParallelSeats: string[];
dagExecMode: "fast" | "sandbox";
parallelBatchRef: MutableRefObject<ParallelSeatView[]>;
executionRefs: ArtifactExecutionRefs;
resolveSeatThreadId: (ids: ThreadIdMap, agentName: string) => string | undefined;
}
/** Owns the Step 2 sandbox/file-preview bridge and its in-memory tool-call stubs. */
export function useStep2ArtifactBridge(options: UseStep2ArtifactBridgeOptions) {
const {
selectedParallelSeats,
dagExecMode,
parallelBatchRef,
executionRefs: {
orchestrationModeRef,
dagExecModeRef,
sandboxFollowThreadIdRef,
threadIdsRef,
lastReconciledSeatRef,
},
resolveSeatThreadId,
} = options;
const {
artifacts,
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
deselect: deselectArtifact,
autoOpen,
autoSelect,
} = useArtifacts();
const {
activeArtifactThreadId,
setActiveArtifactThreadId,
seatStubMessages,
setSeatStubMessages,
seatStubMessagesRef,
} = useStep2ArtifactState();
const artifactPathsRef = useRef<string[]>([]);
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
const hasAutoOpenedRef = useRef<Set<string>>(new Set());
const recommendParallelRef = useRef(false);
useEffect(() => {
artifactPathsRef.current = artifacts ?? [];
}, [artifacts]);
useEffect(() => {
autoOpenFlagsRef.current = { autoOpen, autoSelect };
}, [autoOpen, autoSelect]);
const replaceSeatStubMessages = useCallback(
(
messages: Record<string, StubAiMessage[]>,
settings?: { resetAutoOpen?: boolean },
) => {
seatStubMessagesRef.current = messages;
setSeatStubMessages(messages);
if (settings?.resetAutoOpen) {
hasAutoOpenedRef.current = new Set();
}
},
[seatStubMessagesRef, setSeatStubMessages],
);
const upsertSeatToolCall = useCallback(
(
seatThreadId: string,
toolName: string,
args: Record<string, unknown>,
): { messageId: string; toolCallId: string } => {
const path = typeof args.path === "string" ? args.path : "";
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
const existing = current[0];
const baseMessage: StubAiMessage = existing ?? {
id: `roundtable-stub-${seatThreadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const index = baseMessage.tool_calls.findIndex(
(toolCall) =>
toolCall.name === toolName &&
String((toolCall.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
index >= 0
? baseMessage.tool_calls[index].id
: `tc-${seatThreadId}-${baseMessage.tool_calls.length}`;
const nextToolCall: StubToolCall = {
id: toolCallId,
name: toolName,
args,
};
const nextToolCalls =
index >= 0
? baseMessage.tool_calls.map((toolCall, currentIndex) =>
currentIndex === index ? nextToolCall : toolCall,
)
: [...baseMessage.tool_calls, nextToolCall];
const nextMessage: StubAiMessage = {
...baseMessage,
tool_calls: nextToolCalls,
};
const nextMap = {
...seatStubMessagesRef.current,
[seatThreadId]: [nextMessage],
};
replaceSeatStubMessages(nextMap);
return { messageId: nextMessage.id, toolCallId };
},
[replaceSeatStubMessages, seatStubMessagesRef],
);
const openSeatVirtualArtifact = useCallback(
(
seatThreadId: string,
virtualUrl: string,
auto: boolean,
settings?: { manual?: boolean },
) => {
const manual = settings?.manual ?? !auto;
if (auto) {
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
const flags = autoOpenFlagsRef.current;
if (!flags.autoOpen || !flags.autoSelect) return;
if (
orchestrationModeRef.current === "dag" &&
dagExecModeRef.current === "fast" &&
parallelBatchRef.current.length > 0
) {
return;
}
if (
dagExecModeRef.current === "sandbox" &&
sandboxFollowThreadIdRef.current &&
sandboxFollowThreadIdRef.current !== seatThreadId
) {
return;
}
hasAutoOpenedRef.current.add(virtualUrl);
}
setActiveArtifactThreadId(seatThreadId);
const open = () => {
selectArtifact(virtualUrl, !manual);
setArtifactsOpen(true);
};
if (auto) window.setTimeout(open, 100);
else open();
},
[
dagExecModeRef,
orchestrationModeRef,
parallelBatchRef,
sandboxFollowThreadIdRef,
selectArtifact,
setActiveArtifactThreadId,
setArtifactsOpen,
],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openSeatLatestArtifact = useCallback(
(seatThreadId: string): boolean => {
const stub = seatStubMessagesRef.current[seatThreadId]?.[0];
if (!stub) return false;
for (let index = stub.tool_calls.length - 1; index >= 0; index -= 1) {
const toolCall = stub.tool_calls[index];
if (
toolCall.name !== "write_file" &&
toolCall.name !== "str_replace"
) {
continue;
}
const path =
typeof (toolCall.args as { path?: unknown })?.path === "string"
? ((toolCall.args as { path?: string }).path as string)
: "";
if (!path) continue;
const virtualUrl = buildWriteFileVirtualUrl(
path,
stub.id,
toolCall.id,
);
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, {
manual: false,
});
return true;
}
return false;
},
[
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
seatStubMessagesRef,
],
);
const findSeatArtifactSource = useCallback(
(
rawPath: string,
): {
seatThreadId: string;
messageId: string;
toolCallId: string;
content?: string;
} | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
const readContent = (toolCall: { args?: unknown }) => {
const content = (toolCall.args as { content?: unknown })?.content;
return typeof content === "string" ? content : undefined;
};
let basenameMatch: {
seatThreadId: string;
messageId: string;
toolCallId: string;
content?: string;
} | null = null;
for (const [seatThreadId, messages] of Object.entries(
seatStubMessagesRef.current,
)) {
const message = messages?.[0];
if (!message) continue;
for (const toolCall of message.tool_calls) {
if (
toolCall.name !== "write_file" &&
toolCall.name !== "str_replace"
) {
continue;
}
const path =
typeof (toolCall.args as { path?: unknown })?.path === "string"
? ((toolCall.args as { path?: string }).path as string)
: "";
if (!path) continue;
const match = {
seatThreadId,
messageId: message.id,
toolCallId: toolCall.id,
content: readContent(toolCall),
};
if (path === target) return match;
if (!basenameMatch && (path.split("/").pop() ?? path) === targetBase) {
basenameMatch = match;
}
}
}
return basenameMatch;
},
[seatStubMessagesRef],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string, settings?: { auto?: boolean }) => {
const normalized = path.trim();
if (!normalized) return;
const seatSource = findSeatArtifactSource(normalized);
if (
seatSource &&
typeof seatSource.content === "string" &&
seatSource.content.length > 0
) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
seatSource.messageId,
seatSource.toolCallId,
);
openSeatVirtualArtifact(
seatSource.seatThreadId,
virtualUrl,
settings?.auto ?? false,
);
return;
}
const effectiveThreadId = seatSource?.seatThreadId || threadId;
if (!effectiveThreadId) return;
setActiveArtifactThreadId(effectiveThreadId);
const previous = artifactPathsRef.current;
if (!previous.includes(normalized)) {
const next = [...previous, normalized];
artifactPathsRef.current = next;
setArtifacts(next);
}
const open = () => {
selectArtifact(normalized, settings?.auto ?? false);
setArtifactsOpen(true);
};
if (settings?.auto) window.setTimeout(open, 100);
else open();
},
[
buildWriteFileVirtualUrl,
findSeatArtifactSource,
openSeatVirtualArtifact,
selectArtifact,
setActiveArtifactThreadId,
setArtifacts,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const seatThreadId = dialogue.agentThreadId;
if (!seatThreadId) return;
const [, toolName] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") {
const path = step.args?.path;
if (typeof path !== "string" || !step.args) return;
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
toolName,
step.args,
);
const virtualUrl = buildWriteFileVirtualUrl(
path,
messageId,
toolCallId,
);
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return;
}
if (toolName === "present_files") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
const first = filepaths.find(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.length > 0,
);
if (first) openArtifactPreview(seatThreadId, first);
}
}
},
[
buildWriteFileVirtualUrl,
openArtifactPreview,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
);
const handleArtifactToolPhase = useCallback(
(
phase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
seatThreadId: string | undefined,
) => {
if (phase !== "tool_calling" || !seatThreadId) return;
if (toolName !== "write_file" && toolName !== "str_replace") return;
const path = args?.path;
if (typeof path !== "string" || !path || !args) {
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] tool_calling without path yet",
{ toolName, args },
);
}
return;
}
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
toolName,
args,
);
const virtualUrl = buildWriteFileVirtualUrl(
path,
messageId,
toolCallId,
);
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
},
[
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, seatThreadId: string | undefined) => {
if (!seatThreadId || !Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
const toolCalls = (chunk as Record<string, unknown>).tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== "object") continue;
const object = toolCall as Record<string, unknown>;
const name = typeof object.name === "string" ? object.name : "";
if (name !== "write_file" && name !== "str_replace") continue;
const args = object.args;
if (!args || typeof args !== "object") continue;
const argsObject = args as Record<string, unknown>;
if (typeof argsObject.path !== "string" || !argsObject.path) continue;
upsertSeatToolCall(seatThreadId, name, argsObject);
}
},
[upsertSeatToolCall],
);
useEffect(() => {
const singleColumn =
dagExecMode === "sandbox" || recommendParallelRef.current;
const seat = singleColumn ? selectedParallelSeats[0] ?? "" : "";
const key = `${dagExecMode}:${
recommendParallelRef.current ? "rp" : ""
}:${seat}`;
if (lastReconciledSeatRef.current === key) return;
lastReconciledSeatRef.current = key;
if (!singleColumn || !seat) {
sandboxFollowThreadIdRef.current = null;
return;
}
const ids = threadIdsRef.current;
if (!ids) {
sandboxFollowThreadIdRef.current = null;
return;
}
const threadId = resolveSeatThreadId(ids, seat);
sandboxFollowThreadIdRef.current = threadId ?? null;
if (!threadId) return;
if (!openSeatLatestArtifact(threadId)) {
setActiveArtifactThreadId(null);
deselectArtifact();
}
}, [
dagExecMode,
deselectArtifact,
lastReconciledSeatRef,
openSeatLatestArtifact,
resolveSeatThreadId,
sandboxFollowThreadIdRef,
selectedParallelSeats,
setActiveArtifactThreadId,
threadIdsRef,
]);
const resetArtifactState = useCallback(() => {
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
replaceSeatStubMessages({}, { resetAutoOpen: true });
deselectArtifact();
}, [
deselectArtifact,
replaceSeatStubMessages,
setActiveArtifactThreadId,
setArtifacts,
]);
return {
activeArtifactThreadId,
seatStubMessages,
recommendParallelRef,
replaceSeatStubMessages,
resetArtifactState,
openArtifactPreview,
openArtifactFromStep,
handleArtifactToolPhase,
handleUpstreamFrame,
};
}
@@ -0,0 +1,433 @@
import {
useCallback,
useEffect,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import type {
ModelSwitchInfo,
StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type {
ParallelSeatView,
Step2Dialogue,
} from "@/roundtable-planning/hooks/step2-types";
import {
nowTime,
type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
export interface UseStep2DialogueRuntimeOptions {
setDialogues: Dispatch<SetStateAction<Step2Dialogue[]>>;
triggerToast: (message: string) => void;
setRoles: Dispatch<SetStateAction<RoleEntry[]>>;
setActiveSpeakerId: Dispatch<SetStateAction<number | null>>;
setActiveSpeakerSet: Dispatch<SetStateAction<Set<number>>>;
selectedParallelSeats: string[];
setSelectedParallelSeats: Dispatch<SetStateAction<string[]>>;
parallelStreamRef: MutableRefObject<
Map<number, { text: string; charCount: number }>
>;
parallelFlushTimerRef: MutableRefObject<number | null>;
selectedParallelSeatsRef: MutableRefObject<string[]>;
parallelBatchRef: MutableRefObject<ParallelSeatView[]>;
dagExecModeRef: MutableRefObject<"fast" | "sandbox">;
}
/** Dialogue mutations, throttled parallel rendering, and speaker highlighting for Step 2. */
export function useStep2DialogueRuntime(
options: UseStep2DialogueRuntimeOptions,
) {
const {
setDialogues,
triggerToast,
setRoles,
setActiveSpeakerId,
setActiveSpeakerSet,
selectedParallelSeats,
setSelectedParallelSeats,
parallelStreamRef,
parallelFlushTimerRef,
selectedParallelSeatsRef,
parallelBatchRef,
dagExecModeRef,
} = options;
const appendDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
content: string;
confidence?: string;
}) => {
setDialogues((previous) => [
...previous,
{
id: Date.now() + Math.random(),
sender: card.sender,
avatarType: card.avatarType,
content: card.content,
time: nowTime(),
confidence: card.confidence ?? "实时输出",
},
]);
},
[setDialogues],
);
const phaseLabel = useCallback(
(
phase: StreamPhase,
agentType: "leader" | "special",
toolName?: string,
) => {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "agent_orchestration") return "正在派活";
if (toolName === "ask_clarification") return "正在提问";
if (toolName === "present_files") return "正在交付文件";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return agentType === "leader" ? "正在派活说明" : "正在输出";
default:
return agentType === "leader" ? "调度中" : "正在输出…";
}
},
[],
);
const setDialogueConfidence = useCallback(
(id: number, confidence: string) => {
setDialogues((previous) =>
previous.map((dialogue) =>
dialogue.id === id ? { ...dialogue, confidence } : dialogue,
),
);
},
[setDialogues],
);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((previous) =>
previous.map((dialogue) => {
if (dialogue.id !== bubbleId) return dialogue;
const steps = dialogue.steps ?? [];
const last = steps[steps.length - 1];
if (last?.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...dialogue, steps: next };
}
return dialogue;
}
return {
...dialogue,
steps: [
...steps,
{
id: `${bubbleId}-${steps.length}`,
key,
label,
ts: nowTime(),
args,
},
],
};
}),
);
},
[setDialogues],
);
const appendStreamingDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
initialContent?: string;
confidence?: string;
agentThreadId?: string;
}) => {
const id = Date.now() + Math.random();
setDialogues((previous) => [
...previous,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: card.initialContent ?? "",
time: nowTime(),
confidence: card.confidence ?? "正在输出…",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[setDialogues],
);
const appendToDialogue = useCallback(
(id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((previous) =>
previous.map((dialogue) =>
dialogue.id === id
? { ...dialogue, content: (dialogue.content ?? "") + deltaText }
: dialogue,
),
);
},
[setDialogues],
);
const setDialogueContent = useCallback(
(id: number, fullText: string) => {
setDialogues((previous) =>
previous.map((dialogue) =>
dialogue.id === id ? { ...dialogue, content: fullText } : dialogue,
),
);
},
[setDialogues],
);
const makeModelSwitchHandler = useCallback(
(bubbleId: number, isParallel: boolean) => (info: ModelSwitchInfo) => {
const name = info.agentName || "智能体";
triggerToast(
`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`,
);
if (isParallel) parallelStreamRef.current.delete(bubbleId);
const systemBubble: Step2Dialogue = {
id: Date.now() + Math.random(),
sender: "系统提示",
avatarType: "amber",
content: `模型容错:「${name}」所用模型「${info.failedModel}」${info.reason},已自动切换到「${info.nextModel}」继续。`,
time: nowTime(),
confidence: "模型容错",
};
setDialogues((previous) => {
const index = previous.findIndex((dialogue) => dialogue.id === bubbleId);
if (index < 0) return [...previous, systemBubble];
const cleared = {
...previous[index],
content: "",
confidence: "模型切换中…",
};
const next = [...previous];
next.splice(index, 1, systemBubble, cleared);
return next;
});
},
[parallelStreamRef, setDialogues, triggerToast],
);
const flushParallelStreams = useCallback(() => {
parallelFlushTimerRef.current = null;
const store = parallelStreamRef.current;
if (store.size === 0) return;
const selected = new Set(selectedParallelSeatsRef.current);
for (const seat of parallelBatchRef.current) {
if (!selected.has(seat.agentId)) continue;
const entry = store.get(seat.bubbleId);
if (entry) setDialogueContent(seat.bubbleId, entry.text);
}
}, [
parallelBatchRef,
parallelFlushTimerRef,
parallelStreamRef,
selectedParallelSeatsRef,
setDialogueContent,
]);
const scheduleParallelFlush = useCallback(() => {
if (parallelFlushTimerRef.current !== null) return;
parallelFlushTimerRef.current = window.setTimeout(flushParallelStreams, 80);
}, [flushParallelStreams, parallelFlushTimerRef]);
const ingestParallelDelta = useCallback(
(bubbleId: number, text: string) => {
if (!text) return;
const store = parallelStreamRef.current;
const current = store.get(bubbleId) ?? { text: "", charCount: 0 };
current.text += text;
current.charCount = current.text.length;
store.set(bubbleId, current);
scheduleParallelFlush();
},
[parallelStreamRef, scheduleParallelFlush],
);
const resetParallelStreams = useCallback(() => {
parallelStreamRef.current = new Map();
if (parallelFlushTimerRef.current !== null) {
window.clearTimeout(parallelFlushTimerRef.current);
parallelFlushTimerRef.current = null;
}
}, [parallelFlushTimerRef, parallelStreamRef]);
useEffect(() => resetParallelStreams, [resetParallelStreams]);
useEffect(() => {
const store = parallelStreamRef.current;
if (store.size === 0) return;
const selected = new Set(selectedParallelSeats);
for (const seat of parallelBatchRef.current) {
if (!selected.has(seat.agentId)) continue;
const entry = store.get(seat.bubbleId);
if (entry) setDialogueContent(seat.bubbleId, entry.text);
}
}, [
parallelBatchRef,
parallelStreamRef,
selectedParallelSeats,
setDialogueContent,
]);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((previous) =>
previous.map((dialogue) => {
if (dialogue.id !== id) return dialogue;
const content =
finalContent && finalContent.trim().length > 0
? finalContent
: dialogue.content || "(无文本输出)";
return {
...dialogue,
content,
confidence: confidence ?? dialogue.confidence,
streaming: false,
};
}),
);
},
[setDialogues],
);
const highlightSpeaker = useCallback(
(speakerId: number | null) => {
setActiveSpeakerId(speakerId);
setRoles((previous) =>
previous.map((role) => {
if (speakerId !== null && role.id === speakerId) {
return { ...role, status: "研讨中" };
}
if (role.status === "研讨中") return { ...role, status: "已完成" };
return role;
}),
);
},
[setActiveSpeakerId, setRoles],
);
const markSpeakerStreaming = useCallback(
(speakerId: number) => {
setActiveSpeakerSet(new Set([speakerId]));
setRoles((previous) =>
previous.map((role) => {
if (role.id === speakerId) return { ...role, status: "研讨中" };
if (role.status === "研讨中") return { ...role, status: "已完成" };
return role;
}),
);
},
[setActiveSpeakerSet, setRoles],
);
const markSpeakerIdle = useCallback(
(speakerId: number) => {
setActiveSpeakerSet((previous) => {
if (!previous.has(speakerId)) return previous;
const next = new Set(previous);
next.delete(speakerId);
return next;
});
setRoles((previous) =>
previous.map((role) =>
role.id === speakerId && role.status === "研讨中"
? { ...role, status: "已完成" }
: role,
),
);
},
[setActiveSpeakerSet, setRoles],
);
const clearAllStreamingSpeakers = useCallback(() => {
setActiveSpeakerSet(new Set());
setRoles((previous) =>
previous.map((role) =>
role.status === "研讨中" ? { ...role, status: "已完成" } : role,
),
);
}, [setActiveSpeakerSet, setRoles]);
const markParallelSpeakers = useCallback(
(speakerIds: number[]) => {
const wanted = new Set(speakerIds);
setActiveSpeakerSet(new Set(speakerIds));
setRoles((previous) =>
previous.map((role) => {
if (wanted.has(role.id)) return { ...role, status: "研讨中" };
if (role.status === "研讨中") return { ...role, status: "已完成" };
return role;
}),
);
},
[setActiveSpeakerSet, setRoles],
);
const toggleParallelSeat = useCallback(
(agentId: string) => {
setSelectedParallelSeats((previous) => {
if (dagExecModeRef.current === "sandbox") {
return previous.length === 1 && previous[0] === agentId
? previous
: [agentId];
}
if (previous.includes(agentId)) {
return previous.filter((id) => id !== agentId);
}
const next = [...previous, agentId];
return next.length > 2 ? next.slice(next.length - 2) : next;
});
},
[dagExecModeRef, setSelectedParallelSeats],
);
return {
appendDialogue,
phaseLabel,
setDialogueConfidence,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
setDialogueContent,
makeModelSwitchHandler,
ingestParallelDelta,
resetParallelStreams,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
markParallelSpeakers,
toggleParallelSeat,
};
}
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef } from "react";
import type {
StreamMultiAgentRequest,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { AgentConfig } from "@/roundtable-planning/hooks/step2-types";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
import type { SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
export interface Step2ExecutionRefInputs {
selectedModel: string;
selectedMode: SeatMode;
seatSkillDirectives: Record<string, boolean>;
seatModes: Record<string, SeatMode | null>;
gatherFirst: boolean;
agentConfigs: Record<number, AgentConfig>;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
isOrchestrating: boolean;
orchestrationMode: "recommend" | "chain" | "dag";
orchestrationPlan: OrchestrationPlan | null;
dagExecMode: "fast" | "sandbox";
selectedAgents: SelectedAgent[];
}
/**
* Mutable execution controls and latest-value mirrors consumed by long-running SSE loops.
* Keeping them together documents the concurrency boundary without changing loop behavior.
*/
export function useStep2ExecutionRefs(inputs: Step2ExecutionRefInputs) {
const orchestrationCancelRef = useRef(false);
const orchestrationEpochRef = useRef(0);
const step2InitRef = useRef(false);
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
const pendingInjectPromptRef = useRef<string | null>(null);
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
const selectedModelRef = useRef(inputs.selectedModel);
const selectedModeRef = useRef(inputs.selectedMode);
const seatSkillDirectivesRef = useRef(inputs.seatSkillDirectives);
const seatModesRef = useRef(inputs.seatModes);
const gatherFirstRef = useRef(inputs.gatherFirst);
const gatheredDeliveriesRef = useRef<Array<{ name: string; content: string }>>([]);
const gatherPhaseDoneRef = useRef(false);
const agentConfigsRef = useRef(inputs.agentConfigs);
const agentNameToRoleRef = useRef(inputs.agentNameToRole);
const threadIdsRef = useRef(inputs.threadIds);
const coordinatorNameRef = useRef(inputs.coordinatorName);
const isOrchestratingRef = useRef(inputs.isOrchestrating);
const orchestrationModeRef = useRef(inputs.orchestrationMode);
const orchestrationPlanRef = useRef(inputs.orchestrationPlan);
const dagExecModeRef = useRef(inputs.dagExecMode);
const sandboxFollowThreadIdRef = useRef<string | null>(null);
const lastReconciledSeatRef = useRef("");
const dagPlanExecutedRef = useRef(false);
const selectedAgentsRef = useRef(inputs.selectedAgents);
const abortAllInFlight = useCallback(() => {
activeAbortersRef.current.forEach((controller) => {
try {
controller.abort();
} catch {
// Abort is best-effort; another path may already have completed the request.
}
});
activeAbortersRef.current.clear();
}, []);
const stopOrchestration = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
}, [abortAllInFlight]);
useEffect(() => {
dagExecModeRef.current = inputs.dagExecMode;
}, [inputs.dagExecMode]);
useEffect(() => {
orchestrationModeRef.current = inputs.orchestrationMode;
}, [inputs.orchestrationMode]);
useEffect(() => {
orchestrationPlanRef.current = inputs.orchestrationPlan;
}, [inputs.orchestrationPlan]);
useEffect(() => {
selectedAgentsRef.current = inputs.selectedAgents;
}, [inputs.selectedAgents]);
useEffect(() => {
selectedModelRef.current = inputs.selectedModel;
}, [inputs.selectedModel]);
useEffect(() => {
selectedModeRef.current = inputs.selectedMode;
}, [inputs.selectedMode]);
useEffect(() => {
seatSkillDirectivesRef.current = inputs.seatSkillDirectives;
}, [inputs.seatSkillDirectives]);
useEffect(() => {
seatModesRef.current = inputs.seatModes;
}, [inputs.seatModes]);
useEffect(() => {
gatherFirstRef.current = inputs.gatherFirst;
}, [inputs.gatherFirst]);
useEffect(() => {
agentConfigsRef.current = inputs.agentConfigs;
}, [inputs.agentConfigs]);
useEffect(() => {
agentNameToRoleRef.current = inputs.agentNameToRole;
}, [inputs.agentNameToRole]);
useEffect(() => {
threadIdsRef.current = inputs.threadIds;
}, [inputs.threadIds]);
useEffect(() => {
coordinatorNameRef.current = inputs.coordinatorName;
}, [inputs.coordinatorName]);
useEffect(() => {
isOrchestratingRef.current = inputs.isOrchestrating;
}, [inputs.isOrchestrating]);
return {
orchestrationCancelRef,
orchestrationEpochRef,
step2InitRef,
activeAbortersRef,
pendingInjectPromptRef,
pendingInjectFilesRef,
selectedModelRef,
selectedModeRef,
seatSkillDirectivesRef,
seatModesRef,
gatherFirstRef,
gatheredDeliveriesRef,
gatherPhaseDoneRef,
agentConfigsRef,
agentNameToRoleRef,
threadIdsRef,
coordinatorNameRef,
isOrchestratingRef,
orchestrationModeRef,
orchestrationPlanRef,
dagExecModeRef,
sandboxFollowThreadIdRef,
lastReconciledSeatRef,
dagPlanExecutedRef,
selectedAgentsRef,
abortAllInFlight,
stopOrchestration,
};
}
export type Step2ExecutionRefs = ReturnType<typeof useStep2ExecutionRefs>;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import type {
ClarificationPayload,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type {
AgentConfig,
ParallelSeatView,
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/step2-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
DEFAULT_SELECTED_AGENTS,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
2: {
model: "",
weight: 90,
temp: 0.2,
prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。",
},
3: {
model: "",
weight: 85,
temp: 0.3,
prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。",
},
4: {
model: "",
weight: 88,
temp: 0.1,
prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。",
},
5: {
model: "",
weight: 92,
temp: 0.4,
prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。",
},
6: {
model: "",
weight: 95,
temp: 0.1,
prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。",
},
7: {
model: "",
weight: 80,
temp: 0.4,
prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。",
},
};
/** React state owned by the Step 2 session, separate from execution refs and stream loops. */
export function useStep2SessionState() {
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [initNonce, setInitNonce] = useState(0);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
const [isOrchestrating, setIsOrchestrating] = useState(false);
const [lastLeaderContent, setLastLeaderContent] = useState("");
const [hasConsensus, setHasConsensus] = useState(false);
const [consensusPercentage, setConsensusPercentage] = useState(89.4);
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
Step2Dialogue[]
>([]);
const [pendingClarification, setPendingClarification] =
useState<ClarificationPayload | null>(null);
const [clarificationDraft, setClarificationDraft] = useState("");
const [isRoundTableRunning, setIsRoundTableRunning] = useState(true);
const [activeSpeakerId, setActiveSpeakerId] = useState<number | null>(null);
const [activeSpeakerSet, setActiveSpeakerSet] = useState<Set<number>>(new Set());
const [customAgentInput, setCustomAgentInput] = useState("");
const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState(false);
const [budgetLimit, setBudgetLimit] = useState(500);
const [agentConfigs, setAgentConfigs] = useState<Record<number, AgentConfig>>(
DEFAULT_AGENT_CONFIGS,
);
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
return {
selectedAgents,
setSelectedAgents,
initNonce,
setInitNonce,
threadIds,
setThreadIds,
coordinatorName,
setCoordinatorName,
isInitializing,
setIsInitializing,
initError,
setInitError,
isOrchestrating,
setIsOrchestrating,
lastLeaderContent,
setLastLeaderContent,
hasConsensus,
setHasConsensus,
consensusPercentage,
setConsensusPercentage,
step2RoundtableDialogues,
setStep2RoundtableDialogues,
pendingClarification,
setPendingClarification,
clarificationDraft,
setClarificationDraft,
isRoundTableRunning,
setIsRoundTableRunning,
activeSpeakerId,
setActiveSpeakerId,
activeSpeakerSet,
setActiveSpeakerSet,
customAgentInput,
setCustomAgentInput,
isUpperPanelCollapsed,
setIsUpperPanelCollapsed,
budgetLimit,
setBudgetLimit,
agentConfigs,
setAgentConfigs,
selectedAgentId,
setSelectedAgentId,
};
}
/** Artifact selection and stub-message state for sandbox previews. */
export function useStep2ArtifactState() {
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
null,
);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
return {
activeArtifactThreadId,
setActiveArtifactThreadId,
seatStubMessages,
setSeatStubMessages,
seatStubMessagesRef,
};
}
/** Parallel/DAG visualization state plus refs consumed by throttled stream flushes. */
export function useStep2ParallelState() {
const [parallelBatch, setParallelBatch] = useState<ParallelSeatView[]>([]);
const [dagNodeStates, setDagNodeStates] = useState<Record<string, ChainNodeState>>({});
const [selectedParallelSeats, setSelectedParallelSeats] = useState<string[]>([]);
const parallelStreamRef = useRef<Map<number, { text: string; charCount: number }>>(
new Map(),
);
const parallelFlushTimerRef = useRef<number | null>(null);
const selectedParallelSeatsRef = useRef<string[]>([]);
const parallelBatchRef = useRef<ParallelSeatView[]>([]);
useEffect(() => {
selectedParallelSeatsRef.current = selectedParallelSeats;
}, [selectedParallelSeats]);
useEffect(() => {
parallelBatchRef.current = parallelBatch;
}, [parallelBatch]);
return {
parallelBatch,
setParallelBatch,
dagNodeStates,
setDagNodeStates,
selectedParallelSeats,
setSelectedParallelSeats,
parallelStreamRef,
parallelFlushTimerRef,
selectedParallelSeatsRef,
parallelBatchRef,
};
}
@@ -0,0 +1,160 @@
import {
useCallback,
useRef,
type Dispatch,
type SetStateAction,
} from "react";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/step2-types";
import {
asSubagentTaskEvent,
extractFrameText,
} from "@/roundtable-planning/lib/step2-orchestration-helpers";
/** Converts ultra-seat task frames into shared Subtask cards attached to a dialogue. */
export function useStep2SubtaskFrames(
setDialogues: Dispatch<SetStateAction<Step2Dialogue[]>>,
) {
const updateSubtask = useUpdateSubtask();
const knownTaskIdsRef = useRef<Set<string>>(new Set());
const touchBubbleSubtaskId = useCallback(
(bubbleId: number, taskId: string) => {
setDialogues((previous) =>
previous.map((dialogue) => {
if (dialogue.id !== bubbleId) return dialogue;
const ids = dialogue.subtaskIds ?? [];
return {
...dialogue,
subtaskIds: ids.includes(taskId) ? [...ids] : [...ids, taskId],
};
}),
);
},
[setDialogues],
);
const ingestSubagentMessage = useCallback(
(message: Record<string, unknown>, bubbleId: number) => {
const toolCalls = message.tool_calls;
if (Array.isArray(toolCalls)) {
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== "object") continue;
const object = toolCall as Record<string, unknown>;
if (object.name !== "task") continue;
const taskId = typeof object.id === "string" ? object.id : "";
if (!taskId) continue;
const args = (
object.args && typeof object.args === "object" ? object.args : {}
) as Record<string, unknown>;
updateSubtask({
id: taskId,
status: "in_progress",
subagent_type:
typeof args.subagent_type === "string" ? args.subagent_type : "",
description:
typeof args.description === "string" ? args.description : "",
prompt: typeof args.prompt === "string" ? args.prompt : "",
});
knownTaskIdsRef.current.add(taskId);
touchBubbleSubtaskId(bubbleId, taskId);
}
}
const type =
typeof message.type === "string" ? message.type.toLowerCase() : "";
if (type !== "tool") return;
const taskId =
typeof message.tool_call_id === "string" ? message.tool_call_id : "";
if (!taskId || !knownTaskIdsRef.current.has(taskId)) return;
const result = extractFrameText(message.content);
if (result.startsWith("Task Succeeded. Result:")) {
updateSubtask({
id: taskId,
status: "completed",
result: result.split("Task Succeeded. Result:")[1]?.trim(),
});
} else if (result.startsWith("Task failed.")) {
updateSubtask({
id: taskId,
status: "failed",
error: result.split("Task failed.")[1]?.trim(),
});
} else if (result.startsWith("Task timed out")) {
updateSubtask({ id: taskId, status: "failed", error: result });
}
touchBubbleSubtaskId(bubbleId, taskId);
},
[touchBubbleSubtaskId, updateSubtask],
);
return useCallback(
(frame: unknown, bubbleId: number) => {
const event = asSubagentTaskEvent(frame);
if (event) {
knownTaskIdsRef.current.add(event.task_id);
if (event.type === "task_running") {
updateSubtask({
id: event.task_id,
status: "in_progress",
latestMessage: event.message as never,
});
} else if (event.type === "task_started") {
updateSubtask({
id: event.task_id,
status: "in_progress",
description:
typeof event.description === "string"
? event.description
: undefined,
});
} else if (event.type === "task_completed") {
updateSubtask({
id: event.task_id,
status: "completed",
result:
typeof event.result === "string"
? event.result
: extractFrameText(event.result),
});
} else if (
event.type === "task_failed" ||
event.type === "task_cancelled" ||
event.type === "task_timed_out"
) {
updateSubtask({
id: event.task_id,
status: "failed",
error:
typeof event.error === "string"
? event.error
: "子智能体任务失败",
});
}
touchBubbleSubtaskId(bubbleId, event.task_id);
return;
}
if (frame && typeof frame === "object" && !Array.isArray(frame)) {
const messages = (frame as Record<string, unknown>).messages;
if (Array.isArray(messages)) {
for (const message of messages) {
if (message && typeof message === "object") {
ingestSubagentMessage(
message as Record<string, unknown>,
bubbleId,
);
}
}
}
return;
}
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
ingestSubagentMessage(chunk as Record<string, unknown>, bubbleId);
},
[ingestSubagentMessage, touchBubbleSubtaskId, updateSubtask],
);
}
@@ -262,6 +262,7 @@ export function useStep3Report(
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。 // 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme(); const { theme } = useTheme();
const reportThemeMode: "light" | "dark" = theme === "dark" ? "dark" : "light";
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。 // onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved); const onReportSavedRef = useRef(onReportSaved);
@@ -826,7 +827,7 @@ export function useStep3Report(
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, closing, "绘制完成"); finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done"); setPhase("done");
setReportTheme(theme); setReportTheme(reportThemeMode);
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。 // 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。
if (html.trim()) { if (html.trim()) {
onReportSavedRef.current?.({ onReportSavedRef.current?.({
@@ -834,12 +835,12 @@ export function useStep3Report(
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
model: selectedModel || "", model: selectedModel || "",
summary: closing || "", summary: closing || "",
themeMode: theme, themeMode: reportThemeMode,
}); });
} }
}, },
[ [
theme, reportThemeMode,
selectedModel, selectedModel,
openRealArtifactFallback, openRealArtifactFallback,
reconcileStubFromRealFile, reconcileStubFromRealFile,
@@ -885,7 +886,7 @@ export function useStep3Report(
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
themeMode: theme, themeMode: reportThemeMode,
revisionInstruction, revisionInstruction,
previousHtml: savedReport?.html, previousHtml: savedReport?.html,
}); });
@@ -906,7 +907,7 @@ export function useStep3Report(
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
theme, reportThemeMode,
savedReport?.html, savedReport?.html,
appendStreamingDialogue, appendStreamingDialogue,
streamRound, streamRound,
@@ -953,7 +954,7 @@ export function useStep3Report(
let threadId = liveThreadRef.current; let threadId = liveThreadRef.current;
let prompt: string; let prompt: string;
if (threadId) { if (threadId) {
prompt = buildInterjectPrompt(text, theme); prompt = buildInterjectPrompt(text, reportThemeMode);
} else { } else {
setPhase("creating"); setPhase("creating");
const init = await initReportThread({ signal: aborter.signal }); const init = await initReportThread({ signal: aborter.signal });
@@ -967,7 +968,7 @@ export function useStep3Report(
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
themeMode: theme, themeMode: reportThemeMode,
revisionInstruction: text, revisionInstruction: text,
previousHtml: savedReport?.html, previousHtml: savedReport?.html,
}); });
@@ -1000,7 +1001,7 @@ export function useStep3Report(
freezeStreaming, freezeStreaming,
appendUserDialogue, appendUserDialogue,
appendStreamingDialogue, appendStreamingDialogue,
theme, reportThemeMode,
intentReady, intentReady,
seatDeliveries, seatDeliveries,
consensusText, consensusText,
@@ -1112,7 +1113,7 @@ export function useStep3Report(
// 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。 // 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。
const themeMismatch = const themeMismatch =
phase === "done" && reportTheme !== null && reportTheme !== theme; phase === "done" && reportTheme !== null && reportTheme !== reportThemeMode;
return { return {
phase, phase,
@@ -15,20 +15,15 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import { uploadFiles } from "@/core/uploads/api"; import { uploadFiles } from "@/core/uploads/api";
import type { import type {
DraftStep3SummarySnapshot, DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts"; } from "@/roundtable-planning/api/drafts";
import { import {
buildArtifactDownloadUrl,
initStructureThread, initStructureThread,
initSummaryThread, initSummaryThread,
listSessionArtifacts,
streamMultiAgent, streamMultiAgent,
type StreamMultiAgentRequest, type StreamMultiAgentRequest,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
/** 一个上传文件的 run 引用(与 streamMultiAgent 的 files 入参对齐)。 */ /** 一个上传文件的 run 引用(与 streamMultiAgent 的 files 入参对齐)。 */
@@ -50,13 +45,19 @@ import {
} from "@/roundtable-planning/api/flow-extract"; } from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants"; import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
import { readActionDetail } from "@/core/auth/task-deeplink"; import { readActionDetail } from "@/core/auth/task-deeplink";
import {
buildResumeAskPrompt,
buildSummaryPrompt,
ingestPhaseLabel,
SEMANTIC_ASK_INSTRUCTION,
summaryPhaseLabel,
} from "@/roundtable-planning/lib/step3-summary-prompts";
import { useStep3SummaryRuntime } from "@/roundtable-planning/hooks/useStep3SummaryRuntime";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */ /** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md"; const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
/** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */ /** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */
const SUMMARY_AGENT_NAME = "roundtable-summary"; const SUMMARY_AGENT_NAME = "roundtable-summary";
/** 结构化抽取智能体 id —— 入库轮由它(走 ingest run policy)真正调用入库技能。 */
const STRUCTURE_AGENT_NAME = "roundtable-structure";
export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error"; export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error";
@@ -174,305 +175,6 @@ export interface UseStep3SummaryReturn {
runIngest: (opts?: { note?: string }) => void; runIngest: (opts?: { note?: string }) => void;
} }
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
}
if (intentReady?.objective) {
parts.push("# 一、任务意图");
parts.push(`目标:${intentReady.objective}`);
if (intentReady.constraints?.length) {
parts.push("约束:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
}
if (seatDeliveries.length) {
parts.push("# 二、各席位最终交付");
seatDeliveries.forEach((s, i) => {
parts.push(`## ${i + 1}. ${s.sender}`);
parts.push(s.content?.trim() || "(无内容)");
parts.push("");
});
}
if (consensusText?.trim()) {
parts.push("# 三、总控最终共识结论");
parts.push(consensusText.trim());
parts.push("");
}
return parts.join("\n");
}
/**
* flow-json 形状契约(内联)。roundtable-summary 的 runtime SOUL 为空,模型拿不到契约,
* 所有要画流程图的 prompt 都得带上它,否则模型产出的 JSON 可能解析不出节点。
*/
const FLOW_JSON_SHAPE =
"flow-json 严格用下面这个形状(一个 JSON 对象,`nodes` 是节点数组;示例仅示意结构、勿照抄内容):\n" +
"```flow-json\n" +
'{"title":"流程图标题","nodes":[\n' +
' {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标的完整描述"},\n' +
' {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"该目的的完整内容:名称 + 理由全文","from":["n1"]},\n' +
' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"该行为体的完整内容:名称 + 理由全文","from":["n2"]}\n' +
"]}\n" +
"```\n" +
"字段:`id`(n1/n2… 唯一短串)、`label`(≤12 字,节点标题,取条目核心短语)、" +
"`type`(取 start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" +
"**`group`(该节点所在的层级名称,如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事——画布按它给节点打标签,必须填)**、" +
"**`detail`(该条目的名称 + 理由 / 说明,点击节点时展示——要把要点表达完整,但控制在约 2–4 句,**不必**逐字照抄报告整段)**、" +
"`from`(前驱节点 id 列表,只引用**前面已出现**的节点)。" +
"节点按先后排列、输出**全量**数据、全程简体中文。";
/**
* 「本轮只画流程图、不写报告」的强约束。关键:有的业务链 coordinatorPrompt 自身写着
* 「输出结构化结论 / 按阶段产出报告 / 最终方案总结报告按层级结构输出」,模型在同一线程里
* 会被它锚定,把用户的「结构化输出」当成「继续写结构化的文字报告」→ 产出 Markdown 而非
* flow-json(实测 deepseek-chat 在这类链上必现)。所以必须显式声明本轮压过那些报告措辞。
*/
const FLOW_ONLY_RULE =
"【硬性·本轮只画流程图】本轮唯一产物是一张**流程图**(由节点 + 连线组成的图),不是文字、不是报告、" +
"不是章节、不是结论清单、不是 Markdown:①**不要**调用 write_file、**不要**写或改任何 .md 文件;" +
"②回复里**不要**再出现任何报告正文 / 章节标题(如「## 四…」)/ 编号结论,正文最多一句话说明;" +
"③**忽略**此前关于「方案总结报告」「按层级结构输出报告」「输出结构化结论」「分阶段产出」的一切措辞——" +
"那些是给上一轮文字报告用的,本轮一律不适用;④在回复**最末尾**输出一个**完整**的 ```flow-json 代码块。";
/**
* 流程图的层级画法(修「结构画错」):直接从已有报告/席位交付里**提取实体**,
* 根=任务名称,每层取具体条目当节点,按报告里的对应关系串成分支,排除汇总/阶段名等无关段落。
*/
const FLOW_STRUCTURE_RULE =
"【流程图画法·严格遵守】\n" +
"① 根节点(n1) = 本次任务本身(即报告「任务总览」里的任务名称 / 目标),label 用任务的简短名称(≤12 字)," +
"**不要**用「任务」「目标」这种泛称当 label;其 `group` 填「任务」;\n" +
"② 从第二层起按层级顺序逐层展开:每个层级在报告/交付里通常列了**多条具体条目**" +
"(如『目的』层有 目的1 / 目的2 / 目的3,『行为体』层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…)," +
"把**每一条具体条目各画成一个节点**,label 取该条目核心短语(≤12 字);**不要**把一整层合并成一个笼统节点;\n" +
"③ **每个节点的 `group` 必须填它所在层级的名称**(任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…,取自报告里该层级的真实名称)——画布据此给节点打分类标签;\n" +
"④ **每个节点的 `detail`** 放该条目的名称 + 理由 / 说明,把要点表达完整、约 2–4 句即可(不必逐字照抄报告整段);" +
"**`detail` 必须写成一行**——内部不要出现真实换行,需要分隔就用「;」或「/」,避免 JSON 里出现裸换行;\n" +
"⑤ 连边严格按报告里的**对应关系**串成分支:根任务 → 各『目的』节点;每个『目的』→ 它对应的『行为体』节点;" +
"每个『行为体』→ 它对应的『预期行为·驱动因素』节点 → 对应的『叙事』节点……每一组各成一条自上而下的分支" +
"(报告里有几组就有几条分支,组与组之间不要混连);\n" +
"⑦ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**——" +
"典型规模约 13–16 个节点(如 任务1 + 目的3 + 行为体3 + 预期行为·驱动因素3 + 叙事3);\n" +
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
// 注:原 buildAutoFlowPrompt(在总结线程里续问吐 flow-json)已废弃 —— Phase 1 起自动结构化
// 输出改走后端独立端点 /api/flow-extract/stream(streamFlowExtract),抽取+校验在后端做。
// FLOW_ONLY_RULE / FLOW_STRUCTURE_RULE / FLOW_JSON_SHAPE 仍由手动 FLOW_ASK_INSTRUCTION 引用。
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean;
}): string {
const parts: string[] = [];
if (opts.flowMode) {
parts.push(
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
}
parts.push("");
parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) {
parts.push(
"请严格遵循你的职责设定:全程简体中文。" +
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
);
} else {
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
}
return parts.join("\n");
}
/**
* 用户消息命中结构化/流程图意图时追加给智能体的系统指令(live 线程续问轮直接拼在消息后)。
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出 / 流程图——这里的「结构化输出」指的是画一张**流程图**," +
"不是写结构化的文字报告。**有报告就直接基于报告提取层级条目**(报告已总结过各席位交付,不要重复总结);" +
"没有报告则基于各席位交付。\n" +
FLOW_ONLY_RULE +
"\n" +
FLOW_STRUCTURE_RULE +
"\n" +
FLOW_JSON_SHAPE +
")";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
*/
const FLOW_CONTEXT_ASK_INSTRUCTION =
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, askInstruction, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
"然后是用户的新消息——请自行判断:若是修改意见,就在旧报告基础上增量修改并用 write_file " +
"整文件重写 /mnt/user-data/outputs/方案总结报告.md,再把更新后的报告正文输出到对话;" +
"若只是提问,就只用文字回答、不要改写文件。",
);
parts.push("");
parts.push(buildMaterials(materials));
if (previousMd.trim()) {
parts.push("# 你之前已生成的报告(方案总结报告.md 当前内容)");
parts.push("```markdown");
parts.push(previousMd.trim());
parts.push("```");
parts.push("");
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
if (askInstruction) {
parts.push("");
parts.push(askInstruction);
}
return parts.join("\n");
}
/** 把 SSE 阶段映射成中文状态标签。 */
function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "write_file" || toolName === "str_replace")
return "正在撰写报告";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */
function ingestPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度入库技能";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "read_file") return "读取入库技能说明(SKILL.md)";
if (toolName === "bash" || toolName === "write_file") return "调用入库技能执行入库";
return toolName ? `调用工具:${toolName}` : "调用入库技能";
case "tool_result":
return "处理入库结果";
case "streaming":
return "汇报入库结果";
default:
return "入库中…";
}
}
export function useStep3Summary( export function useStep3Summary(
options: UseStep3SummaryOptions, options: UseStep3SummaryOptions,
@@ -485,7 +187,6 @@ export function useStep3Summary(
coordinatorPrompt, coordinatorPrompt,
chainLevels, chainLevels,
autoFlow, autoFlow,
savedSummary,
onSummarySaved, onSummarySaved,
ingestAvailable, ingestAvailable,
taskId: ingestTaskId, taskId: ingestTaskId,
@@ -540,16 +241,6 @@ export function useStep3Summary(
ingestChainNameRef.current = ingestChainName ?? null; ingestChainNameRef.current = ingestChainName ?? null;
}, [ingestSkill, ingestBusinessCode, ingestChainName]); }, [ingestSkill, ingestBusinessCode, ingestChainName]);
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<SummaryPhase>("idle"); const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -564,483 +255,48 @@ export function useStep3Summary(
// 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。 // 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。
const [pendingFlowClarification, setPendingFlowClarification] = useState<string | null>(null); const [pendingFlowClarification, setPendingFlowClarification] = useState<string | null>(null);
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null); const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]); const {
const [seatStubMessages, setSeatStubMessages] = useState< dialogues,
Record<string, StubAiMessage[]> setDialogues,
>({}); seatStubMessages,
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState< setSeatStubMessages,
string | null activeArtifactThreadId,
>(null); setActiveArtifactThreadId,
stubRef,
const stubRef = useRef<Record<string, StubAiMessage[]>>({}); runningRef,
const runningRef = useRef(false); aborterRef,
const aborterRef = useRef<AbortController | null>(null); liveThreadRef,
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。 currentMdRef,
const liveThreadRef = useRef<string | null>(null); flowActiveRef,
// 当前报告 md 全文(续问写底 / 存草稿用)。 lastAutoOpenedUrlRef,
const currentMdRef = useRef<string>(""); lastAutoThreadRef,
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点: appendStreamingDialogue,
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导, appendUserDialogue,
// 不再要求用户说出「结构化输出/流程图」等关键词。 appendToDialogue,
const flowActiveRef = useRef(false); setDialogueConfidence,
const lastAutoOpenedUrlRef = useRef<string | null>(null); relabelDialogue,
resetDialogueForReuse,
removeDialogue,
appendStepIfNew,
finalizeDialogue,
buildWriteFileVirtualUrl,
setPreviewEnabled,
handleArtifactToolPhase,
handleUpstreamFrame,
openArtifactPreview,
openArtifactFromStep,
openRealArtifactFallback,
reconcileStubFromRealFile,
abort,
freezeStreaming,
seedStubWithCurrentMd,
} = useStep3SummaryRuntime({ outputPath: SUMMARY_OUTPUT_PATH });
// 流程图抽取串行化:在途抽取的 aborter + 当前承接抽取/校验的气泡 id。新一轮抽取会先中止 // 流程图抽取串行化:在途抽取的 aborter + 当前承接抽取/校验的气泡 id。新一轮抽取会先中止
// 上一轮、并复用同一张「调度中」气泡——保证任何时刻只有一张流程图卡片。修复:报告刚结束时 // 上一轮、并复用同一张「调度中」气泡——保证任何时刻只有一张流程图卡片。修复:报告刚结束时
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。 // run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
const flowAborterRef = useRef<AbortController | null>(null); const flowAborterRef = useRef<AbortController | null>(null);
const activeFlowBubbleRef = useRef<number | null>(null); const activeFlowBubbleRef = useRef<number | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
(card: { sender: string; avatarType: string; agentThreadId?: string }): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
/** 追加一条用户(指挥官)气泡 —— ask() 时先回显用户输入。 */
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(追问)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
}, []);
/** 改某气泡的发送方名称(意图分类期间先建中性「调度中」气泡,定了意图再改成对应智能体名复用)。 */
const relabelDialogue = useCallback((id: number, sender: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
}, []);
/**
* 复用一条旧气泡承接新一轮(改名 + 清空内容/步骤 + 重新转圈)。流程图抽取被新一轮抢占时
* 复用上一轮那条气泡,避免遗留一张卡死的「调度中」卡片。
*/
const resetDialogueForReuse = useCallback((id: number, sender: string) => {
setDialogues((prev) =>
prev.map((d) =>
d.id === id
? { ...d, sender, content: "", steps: [], streaming: true, confidence: "调度中", time: nowTime() }
: d,
),
);
}, []);
/** 移除一条气泡(流程图抽取抢占时清掉被取代的那张孤儿「调度中」卡片)。 */
const removeDialogue = useCallback((id: number) => {
setDialogues((prev) => prev.filter((d) => d.id !== id));
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(已写入报告文件,无额外正文)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
// ── stub 写入(幂等,按 (toolName, path) 去重,后续帧覆盖 args)—— 仅 write_file ──
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
const current = stubRef.current[threadId] ?? [];
const base: StubAiMessage = current[0] ?? {
id: `roundtable-summary-stub-${threadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = base.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
// 防抹空:重写早期帧 content 可能为空,保留旧非空内容直到吐出新非空内容。
let storeArgs = args;
if (idx >= 0) {
const newContent = typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
: [...base.tool_calls, nextTc];
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
const findStubSource = useCallback(
(
rawPath: string,
): { threadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { threadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
// 流式期间:phase==="tool_calling" 且 write_file 有可读 path → 写 stub + 自动开沙箱。
const handleArtifactToolPhase = useCallback(
(
streamPhase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
if (toolName !== "write_file") return; // str_replace 无 content,不驱动预览
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
const basename = path.split("/").pop() ?? path;
if (!basename.includes(".")) return; // 半截路径(无扩展名)忽略
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
autoOpenArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
const normalized = path.trim();
if (!normalized) return;
const source = findStubSource(normalized);
if (source) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
source.messageId,
source.toolCallId,
);
openArtifact(source.threadId, virtualUrl);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
setArtifacts([normalized]);
selectArtifact(normalized, false);
setArtifactsOpen(true);
},
[
findStubSource,
buildWriteFileVirtualUrl,
openArtifact,
setArtifacts,
selectArtifact,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
opts?: { signal?: AbortSignal; force?: boolean },
): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const stubPath =
typeof (writeTc?.args as { path?: unknown })?.path === "string"
? ((writeTc!.args as { path?: string }).path as string)
: SUMMARY_OUTPUT_PATH;
const stubContent =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
},
[upsertStub],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 把仍在流式的气泡冻结(停转圈),并打上状态标签(打断 / 暂停时用)。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
/**
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
*/
const seedStubWithCurrentMd = useCallback(
(threadId: string, opts?: { open?: boolean }) => {
const md = currentMdRef.current;
if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: SUMMARY_OUTPUT_PATH,
content: md,
});
if (opts?.open === false || !previewEnabledRef.current) return;
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
/** /**
* 公共流式过程:在 `bubbleId` 气泡上跑一轮 special run 并对账落档。 * 公共流式过程:在 `bubbleId` 气泡上跑一轮 special run 并对账落档。
@@ -0,0 +1,548 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
export interface Step3SummaryRuntimeOptions {
outputPath: string;
}
export function useStep3SummaryRuntime({
outputPath,
}: Step3SummaryRuntimeOptions) {
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
string | null
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
const liveThreadRef = useRef<string | null>(null);
// 当前报告 md 全文(续问写底 / 存草稿用)。
const currentMdRef = useRef<string>("");
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
(card: { sender: string; avatarType: string; agentThreadId?: string; }): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
/** 追加一条用户(指挥官)气泡 —— ask() 时先回显用户输入。 */
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(追问)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
}, []);
/** 改某气泡的发送方名称(意图分类期间先建中性「调度中」气泡,定了意图再改成对应智能体名复用)。 */
const relabelDialogue = useCallback((id: number, sender: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
}, []);
/**
* 复用一条旧气泡承接新一轮(改名 + 清空内容/步骤 + 重新转圈)。流程图抽取被新一轮抢占时
* 复用上一轮那条气泡,避免遗留一张卡死的「调度中」卡片。
*/
const resetDialogueForReuse = useCallback((id: number, sender: string) => {
setDialogues((prev) =>
prev.map((d) =>
d.id === id
? { ...d, sender, content: "", steps: [], streaming: true, confidence: "调度中", time: nowTime() }
: d,
),
);
}, []);
/** 移除一条气泡(流程图抽取抢占时清掉被取代的那张孤儿「调度中」卡片)。 */
const removeDialogue = useCallback((id: number) => {
setDialogues((prev) => prev.filter((d) => d.id !== id));
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(已写入报告文件,无额外正文)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
// ── stub 写入(幂等,按 (toolName, path) 去重,后续帧覆盖 args)—— 仅 write_file ──
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
const current = stubRef.current[threadId] ?? [];
const base: StubAiMessage = current[0] ?? {
id: `roundtable-summary-stub-${threadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = base.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown; })?.path ?? "") === path,
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
// 防抹空:重写早期帧 content 可能为空,保留旧非空内容直到吐出新非空内容。
let storeArgs = args;
if (idx >= 0) {
const newContent = typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown; })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string; }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
: [...base.tool_calls, nextTc];
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
const findStubSource = useCallback(
(
rawPath: string,
): { threadId: string; messageId: string; toolCallId: string; } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { threadId: string; messageId: string; toolCallId: string; }
| null = null;
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown; })?.path === "string"
? ((tc.args as { path?: string; }).path as string)
: "";
if (!p) continue;
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
// 流式期间:phase==="tool_calling" 且 write_file 有可读 path → 写 stub + 自动开沙箱。
const handleArtifactToolPhase = useCallback(
(
streamPhase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
if (toolName !== "write_file") return; // str_replace 无 content,不驱动预览
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
const basename = path.split("/").pop() ?? path;
if (!basename.includes(".")) return; // 半截路径(无扩展名)忽略
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
autoOpenArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
const normalized = path.trim();
if (!normalized) return;
const source = findStubSource(normalized);
if (source) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
source.messageId,
source.toolCallId,
);
openArtifact(source.threadId, virtualUrl);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
setArtifacts([normalized]);
selectArtifact(normalized, false);
setArtifactsOpen(true);
},
[
findStubSource,
buildWriteFileVirtualUrl,
openArtifact,
setArtifacts,
selectArtifact,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
opts?: { signal?: AbortSignal; force?: boolean; },
): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const stubPath =
typeof (writeTc?.args as { path?: unknown; })?.path === "string"
? ((writeTc!.args as { path?: string; }).path as string)
: outputPath;
const stubContent =
typeof (writeTc?.args as { content?: unknown; })?.content === "string"
? ((writeTc!.args as { content?: string; }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
},
[upsertStub],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 把仍在流式的气泡冻结(停转圈),并打上状态标签(打断 / 暂停时用)。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
/**
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
*/
const seedStubWithCurrentMd = useCallback(
(threadId: string, opts?: { open?: boolean; }) => {
const md = currentMdRef.current;
if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: outputPath,
content: md,
});
if (opts?.open === false || !previewEnabledRef.current) return;
const virtualUrl = buildWriteFileVirtualUrl(outputPath, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
return {
dialogues,
setDialogues,
seatStubMessages,
setSeatStubMessages,
activeArtifactThreadId,
setActiveArtifactThreadId,
stubRef,
runningRef,
aborterRef,
liveThreadRef,
currentMdRef,
flowActiveRef,
lastAutoOpenedUrlRef,
lastAutoThreadRef,
appendStreamingDialogue,
appendUserDialogue,
appendToDialogue,
setDialogueConfidence,
relabelDialogue,
resetDialogueForReuse,
removeDialogue,
appendStepIfNew,
finalizeDialogue,
upsertStub,
buildWriteFileVirtualUrl,
openArtifact,
setPreviewEnabled,
autoOpenArtifact,
findStubSource,
handleArtifactToolPhase,
handleUpstreamFrame,
openArtifactPreview,
openArtifactFromStep,
openRealArtifactFallback,
reconcileStubFromRealFile,
abort,
freezeStreaming,
seedStubWithCurrentMd,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
asSubagentTaskEvent,
buildChainLeaderTask,
extractFrameText,
pickChainTask,
takeOnce,
} from "./step2-orchestration-helpers";
test("takeOnce returns the value and clears the ref", () => {
const ref = { current: ["file-a"] as string[] | null };
assert.deepEqual(takeOnce(ref), ["file-a"]);
assert.equal(ref.current, null);
assert.equal(takeOnce(ref), undefined);
});
test("asSubagentTaskEvent accepts bare and namespaced task frames", () => {
assert.deepEqual(
asSubagentTaskEvent({ type: "task_started", task_id: "t-1", message: "go" }),
{
type: "task_started",
task_id: "t-1",
message: "go",
result: undefined,
error: undefined,
description: undefined,
},
);
assert.equal(
asSubagentTaskEvent(["subgraph", { type: "task_completed", task_id: "t-2" }])
?.task_id,
"t-2",
);
assert.equal(asSubagentTaskEvent({ type: "message", task_id: "t-3" }), null);
});
test("extractFrameText joins strings and text blocks while ignoring other blocks", () => {
assert.equal(
extractFrameText(["A", { type: "text", text: "B" }, { type: "image", url: "x" }]),
"AB",
);
assert.equal(extractFrameText({ text: "not-an-array" }), "");
});
test("buildChainLeaderTask pins the correct seat and position", () => {
const first = buildChainLeaderTask({
seedMessage: "任务背景",
position: 0,
total: 3,
seatAgentId: "agent-a",
seatName: "情报席",
isFirst: true,
});
assert.match(first, /任务背景/);
assert.match(first, /第 1\/3 个席位/);
assert.match(first, /agent_name: agent-a/);
const later = buildChainLeaderTask({
seedMessage: "不应再次出现",
position: 1,
total: 3,
seatAgentId: "agent-b",
seatName: "研判席",
isFirst: false,
});
assert.match(later, /第 2\/3 个席位/);
assert.doesNotMatch(later, /不应再次出现/);
});
test("pickChainTask prefers exact dispatch then corrects a mismatched id", () => {
assert.equal(
pickChainTask(
[
["other", "其它任务"],
["AGENT-A", "目标任务"],
],
"",
"agent-a",
"情报席",
),
"目标任务",
);
assert.equal(
pickChainTask([["wrong", "仍使用这条派活"]], "", "agent-a", "情报席"),
"仍使用这条派活",
);
});
test("pickChainTask falls back to leader content or the seat template", () => {
assert.match(pickChainTask([], " 总控说明 ", "agent-a", "情报席"), /总控说明/);
assert.match(pickChainTask([], "", "agent-a", "情报席"), /情报席/);
});
@@ -0,0 +1,110 @@
import type { LeaderDispatch } from "@/roundtable-planning/api/multi-agent";
export interface SubagentTaskEvent {
type: string;
task_id: string;
message?: unknown;
result?: unknown;
error?: unknown;
description?: unknown;
}
/** Reads a consume-once ref and clears it before returning the previous value. */
export function takeOnce<T>(ref: { current: T | null }): T | undefined {
const value = ref.current ?? undefined;
ref.current = null;
return value;
}
/** Normalizes bare and namespaced subagent task events from LangGraph custom frames. */
export function asSubagentTaskEvent(frame: unknown): SubagentTaskEvent | null {
const candidate =
frame && typeof frame === "object" && !Array.isArray(frame)
? (frame as Record<string, unknown>)
: Array.isArray(frame) &&
frame.length >= 2 &&
frame[1] &&
typeof frame[1] === "object"
? (frame[1] as Record<string, unknown>)
: null;
if (!candidate) return null;
const type = typeof candidate.type === "string" ? candidate.type : "";
const taskId = typeof candidate.task_id === "string" ? candidate.task_id : "";
if (!type.startsWith("task_") || !taskId) return null;
return {
type,
task_id: taskId,
message: candidate.message,
result: candidate.result,
error: candidate.error,
description: candidate.description,
};
}
/** Extracts plain text from string or structured LangGraph content blocks. */
export function extractFrameText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
let output = "";
for (const item of content) {
if (typeof item === "string") {
output += item;
} else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
if (typeof block.text === "string") output += block.text;
}
}
return output;
}
/** Builds the leader instruction for one position in a strictly ordered business chain. */
export function buildChainLeaderTask(args: {
seedMessage: string;
position: number;
total: number;
seatAgentId: string;
seatName: string;
isFirst: boolean;
}): string {
const { seedMessage, position, total, seatAgentId, seatName, isFirst } = args;
const ordinal = `${position + 1}/${total}`;
if (isFirst) {
return [
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seedMessage,
"",
`本次研讨严格按预设链条顺序进行,共 ${total} 个席位,现在是第 ${ordinal} 个席位。`,
`请你作为总控,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,结合任务背景为它分配聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
return [
`链条研讨继续,现在轮到第 ${ordinal} 个席位。`,
`请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,为它分配承接前序结论、聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
/** Selects the task for the current chain seat while correcting a misaddressed dispatch. */
export function pickChainTask(
dispatched: LeaderDispatch[],
leaderContent: string,
seatAgentId: string,
seatName: string,
): string {
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) return exact[1];
const any = dispatched.find(([, task]) => task?.trim());
if (any && any[1]?.trim()) return any[1];
if (leaderContent?.trim()) {
return `请你作为「${seatName}」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n${leaderContent.trim()}`;
}
return `请你作为「${seatName}」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。`;
}
@@ -0,0 +1,218 @@
import type { StreamPhase } from "@/roundtable-planning/api/multi-agent";
import type {
ReportIntent,
ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report";
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
}
if (intentReady?.objective) {
parts.push("# 一、任务意图");
parts.push(`目标:${intentReady.objective}`);
if (intentReady.constraints?.length) {
parts.push("约束:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
}
if (seatDeliveries.length) {
parts.push("# 二、各席位最终交付");
seatDeliveries.forEach((s, i) => {
parts.push(`## ${i + 1}. ${s.sender}`);
parts.push(s.content?.trim() || "(无内容)");
parts.push("");
});
}
if (consensusText?.trim()) {
parts.push("# 三、总控最终共识结论");
parts.push(consensusText.trim());
parts.push("");
}
return parts.join("\n");
}
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
export function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean;
}): string {
const parts: string[] = [];
if (opts.flowMode) {
parts.push(
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
}
parts.push("");
parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) {
parts.push(
"请严格遵循你的职责设定:全程简体中文。" +
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
);
} else {
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
}
return parts.join("\n");
}
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
export const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
export function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, askInstruction, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
"然后是用户的新消息——请自行判断:若是修改意见,就在旧报告基础上增量修改并用 write_file " +
"整文件重写 /mnt/user-data/outputs/方案总结报告.md,再把更新后的报告正文输出到对话;" +
"若只是提问,就只用文字回答、不要改写文件。",
);
parts.push("");
parts.push(buildMaterials(materials));
if (previousMd.trim()) {
parts.push("# 你之前已生成的报告(方案总结报告.md 当前内容)");
parts.push("```markdown");
parts.push(previousMd.trim());
parts.push("```");
parts.push("");
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
if (askInstruction) {
parts.push("");
parts.push(askInstruction);
}
return parts.join("\n");
}
/** 把 SSE 阶段映射成中文状态标签。 */
export function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "write_file" || toolName === "str_replace")
return "正在撰写报告";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */
export function ingestPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度入库技能";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "read_file") return "读取入库技能说明(SKILL.md)";
if (toolName === "bash" || toolName === "write_file") return "调用入库技能执行入库";
return toolName ? `调用工具:${toolName}` : "调用入库技能";
case "tool_result":
return "处理入库结果";
case "streaming":
return "汇报入库结果";
default:
return "入库中…";
}
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1290,9 +1290,9 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
* agent surfaces on classic rgba()/hex colours instead. * agent surfaces on classic rgba()/hex colours instead.
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
.agent-avatar { .agent-avatar {
background-color: #feeded; background-color: #edf4ff;
color: #cf1322; color: #1f5fdb;
box-shadow: inset 0 0 0 1px rgba(190, 35, 35, 0.15); box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16);
} }
.dark .agent-avatar { .dark .agent-avatar {
+1 -1
View File
@@ -846,7 +846,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
### WeKnora Retrieval Skill ### WeKnora Retrieval Skill
`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. The frontend source card recognizes it and opens a context dialog; its call to authenticated Gateway route `/api/weknora/source-context` is proxied to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context`, returning only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`. `skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. Both the configured Skill source-card path and the generic MCP-result fallback recognize this URL; the source-information dialog never renders it as text and instead exposes a “查看原文” button. The button makes the authenticated Gateway call to `/api/weknora/source-context`, which then proxies to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context` and returns only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`.
### Embedded Client (`packages/harness/deerflow/client.py`) ### Embedded Client (`packages/harness/deerflow/client.py`)
+1 -1
View File
@@ -78,7 +78,7 @@ Per-thread isolated execution with virtual path translation:
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories - **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory - **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths - **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results now contain a credential-free `url` pointer; the source card's “查看原文” dialog calls DeerFlow's authenticated `/api/weknora/source-context` proxy, which retrieves the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt. - **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results contain a credential-free `url` pointer, which is never rendered as text: both configured Skill source cards and the generic MCP-result fallback recognize it and provide a “查看原文” button. Selecting it calls DeerFlow's authenticated `/api/weknora/source-context` proxy and displays the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt.
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match - **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access) - **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)