feat: refine position roundtable workflow

This commit is contained in:
董宏凯 committed 2026-07-22 22:22:38 +08:00
1 parent 359bd28f2c
commit 13bfa719ee
12 files changed
+1749 -369

No files matched your search

+4 -2
View File
@@ -75,11 +75,13 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
} }
>(({ className, children, showCloseButton = true, ...props }, ref) => { >(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
data-slot="dialog-content" data-slot="dialog-content"
@@ -124,6 +124,7 @@ export function ArtifactFileDetail({
onClose, onClose,
allowVirtualMarkdownEdit = false, allowVirtualMarkdownEdit = false,
disableDesensitize = false, disableDesensitize = false,
hideFileName = false,
}: { }: {
className?: string; className?: string;
filepath: string; filepath: string;
@@ -142,6 +143,7 @@ export function ArtifactFileDetail({
* 默认 false,沿用全局脱敏。 * 默认 false,沿用全局脱敏。
*/ */
disableDesensitize?: boolean; disableDesensitize?: boolean;
hideFileName?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
@@ -218,7 +220,8 @@ export function ArtifactFileDetail({
const [hasLocalEdits, setHasLocalEdits] = useState(false); const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent(); const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? ""); const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading; const isAutoPreviewLanguage = language === "html" || language === "markdown";
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => { const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG"; if (isImageFile(filepath)) return "IMG";
@@ -262,9 +265,12 @@ export function ArtifactFileDetail({
setAllowAutoViewSwitch(true); setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true); setAutoScrollEnabled(true);
setShowResumeAutoScroll(false); setShowResumeAutoScroll(false);
setShowPreviewSwitchPulse(false);
}, [filepathFromProps]);
if (language === "html") { useEffect(() => {
setViewMode(thread.isLoading ? "code" : "preview"); if (isPreviewStreaming) {
setViewMode("code");
return; return;
} }
@@ -274,10 +280,10 @@ export function ArtifactFileDetail({
} }
setViewMode("code"); setViewMode("code");
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]); }, [isPreviewStreaming, isSupportPreview]);
useEffect(() => { useEffect(() => {
if (!allowAutoViewSwitch || language !== "html") { if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
return; return;
} }
@@ -293,7 +299,7 @@ export function ArtifactFileDetail({
}, 1400); }, 1400);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, language, thread.isLoading]); }, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
const scrollArtifactToBottom = useCallback( const scrollArtifactToBottom = useCallback(
(behavior: ScrollBehavior = "auto") => { (behavior: ScrollBehavior = "auto") => {
@@ -576,28 +582,30 @@ export function ArtifactFileDetail({
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8"> <span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel} {fileTypeLabel}
</span> </span>
<ArtifactTitle className="min-w-0"> {!hideFileName && (
{useWriteFileStreaming ? ( <ArtifactTitle className="min-w-0">
<div className="truncate px-3">{getFileName(filepath)}</div> {useWriteFileStreaming ? (
) : hasMultipleArtifacts ? ( <div className="truncate px-3">{getFileName(filepath)}</div>
<Select value={filepath} onValueChange={select}> ) : hasMultipleArtifacts ? (
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0"> <Select value={filepath} onValueChange={select}>
<SelectValue placeholder="Select a file" /> <SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
</SelectTrigger> <SelectValue placeholder="Select a file" />
<SelectContent className="select-none"> </SelectTrigger>
<SelectGroup> <SelectContent className="select-none">
{(artifacts ?? []).map((artifactPath) => ( <SelectGroup>
<SelectItem key={artifactPath} value={artifactPath}> {(artifacts ?? []).map((artifactPath) => (
{getFileName(artifactPath)} <SelectItem key={artifactPath} value={artifactPath}>
</SelectItem> {getFileName(artifactPath)}
))} </SelectItem>
</SelectGroup> ))}
</SelectContent> </SelectGroup>
</Select> </SelectContent>
) : ( </Select>
<div className="truncate px-3">{getFileName(filepath)}</div> ) : (
)} <div className="truncate px-3">{getFileName(filepath)}</div>
</ArtifactTitle> )}
</ArtifactTitle>
)}
</div> </div>
</div> </div>
<div className="flex w-full items-center justify-center md:w-auto md:flex-1"> <div className="flex w-full items-center justify-center md:w-auto md:flex-1">
@@ -634,7 +642,7 @@ export function ArtifactFileDetail({
<EyeIcon /> <EyeIcon />
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
{isHtmlStreaming && ( {isPreviewStreaming && (
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]"> <div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" /> <span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
正在生成,完成后自动预览 正在生成,完成后自动预览
@@ -27,10 +27,12 @@ export function ArtifactFileList({
className, className,
files, files,
threadId, threadId,
onOpenArtifact,
}: { }: {
className?: string; className?: string;
files: string[]; files: string[];
threadId: string; threadId: string;
onOpenArtifact?: (filepath: string) => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { isMock, publicShareCode } = useThread(); const { isMock, publicShareCode } = useThread();
@@ -40,10 +42,14 @@ export function ArtifactFileList({
const handleClick = useCallback( const handleClick = useCallback(
(filepath: string) => { (filepath: string) => {
if (onOpenArtifact) {
onOpenArtifact(filepath);
return;
}
selectArtifact(filepath); selectArtifact(filepath);
setOpen(true); setOpen(true);
}, },
[selectArtifact, setOpen], [onOpenArtifact, selectArtifact, setOpen],
); );
const handleInstallSkill = useCallback( const handleInstallSkill = useCallback(
@@ -104,6 +104,7 @@ export function MessageList({
onSetupWritingApprove, onSetupWritingApprove,
onSetupWritingReject, onSetupWritingReject,
onSetupWritingStart, onSetupWritingStart,
onOpenArtifact,
}: { }: {
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
@@ -123,6 +124,7 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>; onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */ /** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>; onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onOpenArtifact?: (filepath: string) => void;
}) { }) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo( const rehypePlugins = useMemo(
@@ -488,7 +490,11 @@ export function MessageList({
className="mb-4" className="mb-4"
/> />
)} )}
<ArtifactFileList files={files} threadId={threadId} /> <ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
/>
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
isLoading={thread.isLoading} isLoading={thread.isLoading}
+20
View File
@@ -23,6 +23,26 @@ export async function loadArtifactContent({
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode }); const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url); const response = await apiFetch(url);
const text = await response.text(); const text = await response.text();
if (!response.ok) {
let detail = text.trim();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const parsedDetail =
typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
if (parsedDetail) {
detail = parsedDetail;
}
} catch {
// Keep the raw response text as the diagnostic detail.
}
throw new Error(
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
return { content: text, url }; return { content: text, url };
} }
+5 -3
View File
@@ -22,10 +22,11 @@ export function urlOfArtifact({
publicShareCode, publicShareCode,
)}/files${normalized}`; )}/files${normalized}`;
} }
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) { if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`; return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
} }
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`; return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
} }
export function extractArtifactsFromThread(thread: AgentThread) { export function extractArtifactsFromThread(thread: AgentThread) {
@@ -33,5 +34,6 @@ export function extractArtifactsFromThread(thread: AgentThread) {
} }
export function resolveArtifactURL(absolutePath: string, threadId: string) { export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`; const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
} }
+68 -17
View File
@@ -45,12 +45,46 @@ export type ThreadStreamOptions = {
type SendMessageOptions = { type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>; additionalKwargs?: Record<string, unknown>;
/**
* Extra human-context messages submitted before the visible user message.
* They are persisted with `hide_from_ui` so the model receives rich business
* context without dumping long prompts into the conversation surface.
*/
hiddenContext?:
| string
| {
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}
| Array<{
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}>;
}; };
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & { type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean; title_provisional?: boolean;
}; };
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
const value = options?.hiddenContext;
if (!value) return [];
const items =
typeof value === "string"
? [{ text: value }]
: Array.isArray(value)
? value
: [value];
return items
.map((item) => ({
...item,
text: item.text.trim(),
}))
.filter((item) => item.text.length > 0);
}
function normalizeStoredRunId(runId: string | null): string | null { function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) { if (!runId) {
return null; return null;
@@ -965,26 +999,43 @@ export function useThreadStream({
...extraContext, ...extraContext,
}; };
await thread.submit( const hiddenContextMessages = normalizeHiddenContextMessages(options);
{ const submittedMessages: Message[] = [
messages: [ ...hiddenContextMessages.map((item) => ({
type: "human" as const,
...(item.name ? { name: item.name } : {}),
content: [
{ {
type: "human", type: "text" as const,
content: [ text: item.text,
{
type: "text",
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
}, },
], ],
additional_kwargs: {
...item.additionalKwargs,
hide_from_ui: true,
},
})),
{
type: "human" as const,
content: [
{
type: "text" as const,
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
];
await thread.submit(
{
messages: submittedMessages,
}, },
{ {
threadId: threadId, threadId: threadId,
@@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
export type PositionBuiltinAgentKind = "summary" | "action_plan";
export interface PositionBuiltinAgentSwitchOption {
kind: PositionBuiltinAgentKind;
label: string;
disabled?: boolean;
disabledReason?: string;
}
interface PositionBuiltinAgentTabsProps {
activeKind: PositionBuiltinAgentKind | null;
options: PositionBuiltinAgentSwitchOption[];
isStreaming?: boolean;
onSelect: (kind: PositionBuiltinAgentKind) => void;
}
/**
* The two closing agents are intentionally exposed as full-name buttons
* instead of another dropdown: users can see the whole progression and jump
* between the two stages with one click.
*/
export function PositionBuiltinAgentTabs({
activeKind,
options,
isStreaming,
onSelect,
}: PositionBuiltinAgentTabsProps) {
if (options.length === 0) return null;
return (
<div
className="ml-auto mr-12 flex shrink-0 items-center gap-1.5"
aria-label="收口智能体切换"
title={isStreaming ? "当前智能体正在生成,完成后可切换" : "切换收口智能体"}
>
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
收口智能体
</span>
{options.map((option) => {
const active = activeKind === option.kind;
const disabled = Boolean(option.disabled || (isStreaming && !active));
return (
<button
key={option.kind}
type="button"
disabled={disabled}
aria-pressed={active}
title={disabled ? option.disabledReason || "当前阶段暂不可进入" : option.label}
onClick={() => onSelect(option.kind)}
className={cn(
"inline-flex h-8 min-w-[126px] items-center justify-center rounded-lg border px-2.5 text-xs font-semibold transition",
active
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]",
disabled &&
"cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 opacity-75 hover:border-slate-200 hover:text-slate-400 dark:border-[#2b8ffe]/20 dark:bg-[#082d5f]/55 dark:text-[#7595be]",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,675 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "lucide-react";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
import {
completePositionActionPlan,
completePositionSummary,
preparePositionActionPlan,
preparePositionSummary,
type PositionRoundtableSession,
} from "@/position-roundtable/api/sessions";
import {
initPositionActionPlanThread,
initSummaryThread,
listSessionArtifacts,
} from "@/roundtable-planning/api/multi-agent";
export type PositionBuiltinAgentKind = "summary" | "action_plan";
interface BuiltinArtifact {
name: string;
path: string;
mimeType: string | null;
}
interface PendingBuiltinSubmission {
threadId: string;
agentId: string;
message: PromptInputMessage;
prompt: string;
sourceRevisions: Record<string, number>;
}
interface PositionBuiltinAgentChatProps {
kind: PositionBuiltinAgentKind;
taskId: string;
session: PositionRoundtableSession | null;
allDelivered: boolean;
summaryDone: boolean;
selectedModel: string;
/** Increments only for an explicit flow/dropdown selection that should begin a fresh first turn. */
autoStartRequestKey?: number;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
onSessionUpdate: (session: PositionRoundtableSession) => void;
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
auto?: boolean;
}) => void;
onThreadSnapshot?: (snapshot: {
threadId: string;
thread: BaseStream<AgentThreadState>;
}) => void;
}
const SUMMARY_AGENT_ID = "roundtable-summary";
const ACTION_PLAN_AGENT_ID = "position-action-planner";
function builtinAgentId(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
}
function builtinAgentFallbackName(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? "方案总结智能体" : "行动规划智能体";
}
function builtinTitle(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? "方案总结" : "行动规划";
}
function builtinDescription(kind: PositionBuiltinAgentKind) {
return kind === "summary"
? "汇总全部岗位交付,形成最终方案建议、关键依据和风险提示。"
: "基于方案总结继续拆解行动步骤、责任建议、时间节奏和子任务清单。";
}
function defaultStartText(kind: PositionBuiltinAgentKind) {
return kind === "summary"
? "请基于当前所有岗位交付,开始生成方案总结。"
: "请基于当前方案总结,开始生成行动规划。";
}
function recordString(record: Record<string, unknown> | null | undefined, key: string): string {
const value = record?.[key];
return typeof value === "string" ? value : "";
}
function snapshotStatus(snapshot: Record<string, unknown> | null | undefined) {
const status = recordString(snapshot, "status");
return status || "pending";
}
function snapshotArtifact(
snapshot: Record<string, unknown> | null | undefined,
key: string,
): Record<string, unknown> | null {
const value = snapshot?.[key];
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function artifactName(artifact: Record<string, unknown> | null | undefined): string {
const name = recordString(artifact, "name").trim();
const path = recordString(artifact, "path").trim();
return name || path.split("/").filter(Boolean).at(-1) || "未命名产物";
}
function artifactPath(artifact: Record<string, unknown> | null | undefined): string {
return recordString(artifact, "path").trim();
}
function artifactMimeType(artifact: Record<string, unknown> | null | undefined): string | null {
return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null;
}
function readBuiltinArtifacts(
snapshot: Record<string, unknown> | null | undefined,
): BuiltinArtifact[] {
const seen = new Set<string>();
return ["primary_artifact", "subtasks_artifact"].flatMap((key) => {
const artifact = snapshotArtifact(snapshot, key);
const path = artifactPath(artifact);
if (!path || seen.has(path)) return [];
seen.add(path);
return [
{
path,
name: artifactName(artifact),
mimeType: artifactMimeType(artifact),
},
];
});
}
function artifactDisplayName(pathOrUrl: string): string {
let path = pathOrUrl;
if (pathOrUrl.startsWith("write-file:")) {
try {
path = decodeURIComponent(new URL(pathOrUrl).pathname);
} catch {
path = pathOrUrl;
}
}
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
}
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString();
}
function latestStreamingWriteFile(
messages: AgentThreadState["messages"],
): { path: string; name: string; mimeType: string | null } | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex] as (typeof messages)[number] & {
id?: string;
tool_calls?: Array<{ id?: string; name?: string; args?: Record<string, unknown> }>;
};
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
const rawPath = toolCall.args?.path;
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
const content = toolCall.args?.content;
if (typeof content !== "string" || content.length === 0) continue;
return {
path: buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id),
name: artifactDisplayName(rawPath),
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
};
}
}
return null;
}
function latestAssistantText(messages: AgentThreadState["messages"]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== "ai") continue;
const text = textOfMessage(message)?.trim();
if (text) return text;
}
return "";
}
function manifestFromArtifacts(
files: Awaited<ReturnType<typeof listSessionArtifacts>>["files"],
): Array<Record<string, unknown>> {
return files.map((file) => ({
name: file.name,
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
}));
}
export function PositionBuiltinAgentChat({
kind,
taskId,
session,
allDelivered,
summaryDone,
selectedModel,
autoStartRequestKey,
builtinAgentOptions,
onSelectBuiltinAgent,
onSessionUpdate,
onConversationStart,
onPreviewArtifact,
onThreadSnapshot,
}: PositionBuiltinAgentChatProps) {
const [settings, setSettings] = useLocalSettings();
const fallbackAgentId = builtinAgentId(kind);
const snapshot =
kind === "summary" ? session?.summarySnapshot : session?.actionPlanSnapshot;
const persistedThreadId =
(kind === "summary" ? session?.summaryThreadId : session?.actionPlanThreadId) ||
recordString(snapshot, "thread_id") ||
null;
const snapshotDone = snapshotStatus(snapshot) === "done";
const snapshotStale = snapshotStatus(snapshot) === "stale";
const [assistantId, setAssistantId] = useState(fallbackAgentId);
const { agent } = useAgent(assistantId);
const agentDisplayName = desensitize(agent?.name || builtinAgentFallbackName(kind));
const [boundThreadId, setBoundThreadId] = useState<string | null>(persistedThreadId);
const boundThreadIdRef = useRef<string | null>(persistedThreadId);
const draftThreadIdRef = useRef(uuid());
const pendingRunRef = useRef<{
threadId: string;
sourceRevisions: Record<string, number>;
model?: string;
} | null>(null);
const [pendingSubmission, setPendingSubmission] =
useState<PendingBuiltinSubmission | null>(null);
const pendingSubmissionRef = useRef(false);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
useEffect(() => {
setAssistantId(fallbackAgentId);
setBoundThreadId(persistedThreadId);
boundThreadIdRef.current = persistedThreadId;
draftThreadIdRef.current = uuid();
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
setPendingSubmission(null);
autoPreviewedArtifactKeyRef.current = null;
handledAutoStartRequestRef.current = undefined;
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
const canWrite =
Boolean(session) &&
session?.status !== "archived" &&
allDelivered &&
(kind === "summary" || summaryDone);
const readOnlyHint =
session?.status === "archived"
? "该会话已归档,仅可查看历史对话和产物"
: !allDelivered
? "所有岗位交付完成后,才能进入收口智能体对话"
: kind === "action_plan" && !summaryDone
? "请先进入方案总结智能体并完成有效总结,再继续行动规划"
: "";
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId,
context: {
...settings.context,
agent_id: assistantId,
...(selectedModel ? { model: selectedModel } : {}),
},
threadMetadata: {
thread_type: "position_roundtable_builtin",
taskId,
position_roundtable_task_id: taskId,
position_session_id: session?.id,
position_builtin_kind: kind,
agent_id: assistantId,
},
onStart: (threadId) => {
boundThreadIdRef.current = threadId;
setBoundThreadId(threadId);
},
onFinish: (state) => {
const pending = pendingRunRef.current;
if (!pending || !session) return;
const latestAnswer = latestAssistantText(state.messages ?? []);
if (!latestAnswer) return;
pendingRunRef.current = null;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
try {
const artifacts = await listSessionArtifacts(pending.threadId);
artifactManifest = manifestFromArtifacts(artifacts.files);
} catch {
// Keep the text turn usable even if the artifact endpoint is briefly
// unavailable; the completion API will surface a clearer validation
// message if an output file is genuinely missing.
}
try {
const next =
kind === "summary"
? await completePositionSummary(session.id, {
threadId: pending.threadId,
content: latestAnswer,
artifactManifest,
sourceRevisions: pending.sourceRevisions,
model: pending.model,
})
: await completePositionActionPlan(session.id, {
threadId: pending.threadId,
content: latestAnswer,
artifactManifest,
sourceRevisions: pending.sourceRevisions,
model: pending.model,
});
onSessionUpdate(next);
} catch (error) {
toast.error(
`${builtinTitle(kind)}结果归档失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
})();
},
});
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const displayThreadId = boundThreadId ?? `position-builtin-${kind}-${session?.id ?? "draft"}`;
const threadContextValue = useMemo(() => ({ thread }), [thread]);
const isSubmitting = pendingSubmission !== null;
const status = thread.error ? "error" : thread.isLoading || isSubmitting ? "streaming" : "ready";
const persistedArtifacts = useMemo(() => readBuiltinArtifacts(snapshot), [snapshot]);
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
boundThreadIdRef.current = actualThreadId;
setBoundThreadId(actualThreadId);
}, [actualThreadId]);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]);
const streamingArtifact = useMemo(
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
[thread.isLoading, thread.messages],
);
useEffect(() => {
if (!thread.isLoading || !streamingArtifact) return;
const artifactKey = `${activeThreadId}:${streamingArtifact.path}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId: activeThreadId,
path: streamingArtifact.path,
name: streamingArtifact.name,
mimeType: streamingArtifact.mimeType,
auto: true,
});
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
const ensureBuiltinThread = useCallback(
async (preparedThreadId: string | null, preparedAgentId: string) => {
if (preparedThreadId) {
return { agentName: preparedAgentId, threadId: preparedThreadId };
}
if (boundThreadIdRef.current) {
return { agentName: preparedAgentId, threadId: boundThreadIdRef.current };
}
return kind === "summary" ? initSummaryThread() : initPositionActionPlanThread();
},
[kind],
);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!session) {
toast.warning("请先启动业务链");
return;
}
if (!canWrite) {
toast.info(readOnlyHint || "当前阶段暂不可对话");
return;
}
if (pendingSubmissionRef.current || thread.isLoading) {
return;
}
pendingSubmissionRef.current = true;
const visibleText = message.text.trim() || defaultStartText(kind);
const followUp = kind === "summary" && !snapshotDone ? "" : visibleText;
try {
onConversationStart?.();
const preparation =
kind === "summary"
? await preparePositionSummary(session.id, followUp)
: await preparePositionActionPlan(session.id, followUp);
setAssistantId(preparation.agentId);
const builtinThread = await ensureBuiltinThread(preparation.threadId, preparation.agentId);
boundThreadIdRef.current = builtinThread.threadId;
setBoundThreadId(builtinThread.threadId);
pendingRunRef.current = {
threadId: builtinThread.threadId,
sourceRevisions: preparation.sourceRevisions,
model: selectedModel || undefined,
};
// `useThreadStream` is still attached to the previous (or empty)
// thread during this render. Queue the first turn and dispatch it
// only after the hook has rebound to the prepared thread; otherwise
// the UI can show the optimistic user bubble without opening a run.
setPendingSubmission({
threadId: builtinThread.threadId,
agentId: preparation.agentId,
message: { ...message, text: visibleText },
prompt: preparation.prompt,
sourceRevisions: preparation.sourceRevisions,
});
} catch (error) {
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
}
},
[
canWrite,
ensureBuiltinThread,
kind,
onConversationStart,
readOnlyHint,
selectedModel,
session,
snapshotDone,
thread.isLoading,
],
);
useEffect(() => {
if (
!pendingSubmission ||
thread.isLoading ||
actualThreadId !== pendingSubmission.threadId ||
assistantId !== pendingSubmission.agentId
) {
return;
}
const submission = pendingSubmission;
setPendingSubmission(null);
void (async () => {
try {
await sendMessage(
submission.threadId,
submission.message,
{
agent_id: submission.agentId,
...(selectedModel ? { model: selectedModel } : {}),
},
{
hiddenContext: {
text: submission.prompt,
name: "position_builtin_context",
additionalKwargs: {
position_builtin_kind: kind,
position_session_id: session?.id,
source_revisions: submission.sourceRevisions,
},
},
},
);
} catch (error) {
pendingRunRef.current = null;
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
pendingSubmissionRef.current = false;
}
})();
}, [actualThreadId, assistantId, kind, pendingSubmission, selectedModel, sendMessage, session?.id, thread.isLoading]);
const handleStop = useCallback(() => {
pendingSubmissionRef.current = false;
pendingRunRef.current = null;
setPendingSubmission(null);
void thread.stop();
}, [thread]);
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: artifactDisplayName(filepath),
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
useEffect(() => {
if (
!autoStartRequestKey ||
handledAutoStartRequestRef.current === autoStartRequestKey ||
!canWrite ||
snapshotDone ||
thread.isLoading
) {
return;
}
handledAutoStartRequestRef.current = autoStartRequestKey;
void handleSubmit({ text: defaultStartText(kind), files: [] });
}, [autoStartRequestKey, canWrite, handleSubmit, kind, snapshotDone, thread.isLoading]);
const Icon = kind === "summary" ? Sparkles : Target;
const snapshotLabel = snapshotDone ? "已完成" : snapshotStale ? "需重做" : "待对话";
return (
<ThreadContext.Provider value={threadContextValue}>
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex min-h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-2 dark:border-[#2b8ffe]/20">
<span className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{canWrite ? <Icon className="size-4" /> : <LockKeyhole className="size-3.5" />}
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{agentDisplayName}
</h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || builtinDescription(kind)}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold",
snapshotDone
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: snapshotStale
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]",
)}
>
{snapshotLabel}
</span>
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={kind}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
</header>
{persistedArtifacts.length > 0 && (
<section className="shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
<FileText className="size-3.5 text-primary" />
阶段产物
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{persistedArtifacts.length}
</span>
</div>
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
{persistedArtifacts.map((artifact) => (
<button
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
onClick={() => {
if (boundThreadIdRef.current) {
onPreviewArtifact?.({
threadId: boundThreadIdRef.current,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
<FileText className="size-3 shrink-0" />
<span className="max-w-48 truncate">{artifact.name}</span>
</button>
))}
</div>
</section>
)}
{boundThreadId ? (
<MessageList
className="min-h-0 flex-1"
threadId={boundThreadId}
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Bot className="size-5" />
</span>
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
{canWrite ? `进入${builtinTitle(kind)}智能体对话` : "当前阶段尚未解锁"}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
{canWrite
? "你可以直接发送开始生成,也可以先补充口径、约束或输出格式要求。系统会自动带入全部有效岗位产物。"
: readOnlyHint}
</p>
{canWrite && (
<button
type="button"
disabled={isSubmitting}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleSubmit({ text: defaultStartText(kind), files: [] })}
>
<CheckCircle2 className="size-3.5" />
{kind === "summary" ? "开始方案总结" : "开始行动规划"}
</button>
)}
</div>
)}
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<InputBox
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
assistantId={assistantId}
status={status}
context={settings.context}
disabled={!canWrite || isSubmitting}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
onSubmit={(message) => void handleSubmit(message)}
onStop={handleStop}
/>
</div>
</div>
</ThreadContext.Provider>
);
}
@@ -7,7 +7,6 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { useAgent } from "@/core/agents"; import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
@@ -22,6 +21,11 @@ import {
} from "@/position-roundtable/api/sessions"; } from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent"; import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentKind,
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
interface PositionNodeSwitchOption { interface PositionNodeSwitchOption {
node: PositionRoundtableNode; node: PositionRoundtableNode;
@@ -39,6 +43,8 @@ interface PositionNodeChatProps {
agentName?: string | null; agentName?: string | null;
positionNodeOptions?: PositionNodeSwitchOption[]; positionNodeOptions?: PositionNodeSwitchOption[];
onSelectPositionNode?: (node: PositionRoundtableNode) => void; onSelectPositionNode?: (node: PositionRoundtableNode) => void;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
onNodeUpdate: (node: PositionRoundtableNode) => void; onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */ /** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void; onConversationStart?: () => void;
@@ -108,10 +114,7 @@ function hasAnyArtifact(manifest: Array<Record<string, unknown>>): boolean {
} }
function nodeSwitchStatusLabel(node: PositionRoundtableNode): string { function nodeSwitchStatusLabel(node: PositionRoundtableNode): string {
if (node.invalidatedBy || node.status === "stale") { if (node.invalidatedBy || node.status === "stale") return "需重做";
return "需重做";
}
switch (node.status) { switch (node.status) {
case "running": case "running":
return "进行中"; return "进行中";
@@ -193,6 +196,8 @@ export function PositionNodeChat({
agentName, agentName,
positionNodeOptions, positionNodeOptions,
onSelectPositionNode, onSelectPositionNode,
builtinAgentOptions,
onSelectBuiltinAgent,
onNodeUpdate, onNodeUpdate,
onConversationStart, onConversationStart,
onPreviewArtifact, onPreviewArtifact,
@@ -208,16 +213,10 @@ export function PositionNodeChat({
: [{ node, agentName }], : [{ node, agentName }],
[agentName, node, positionNodeOptions], [agentName, node, positionNodeOptions],
); );
const artifactsCtx = useArtifactsOptional();
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId); const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId); const boundThreadIdRef = useRef<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid()); const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null); const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
// The parent opens the outer sandbox by updating the shared
// ArtifactsProvider. Forward that provider selection only once per file and
// thread: opening the sandbox itself changes the provider value, which used
// to re-run this effect and re-open the same sandbox indefinitely.
const forwardedArtifactKeyRef = useRef<string | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null); const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]); const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite = const canWrite =
@@ -227,31 +226,6 @@ export function PositionNodeChat({
node.status !== "error" && node.status !== "error" &&
!node.invalidatedBy; !node.invalidatedBy;
useEffect(() => {
const selectedArtifact = artifactsCtx?.open
? artifactsCtx.selectedArtifact
: null;
if (!selectedArtifact) {
forwardedArtifactKeyRef.current = null;
return;
}
const threadId =
boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current;
const artifactKey = `${threadId}:${selectedArtifact}`;
if (forwardedArtifactKeyRef.current === artifactKey) return;
forwardedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId,
path: selectedArtifact,
name: selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: selectedArtifact.toLowerCase().endsWith(".md")
? "text/markdown"
: null,
});
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
useEffect(() => { useEffect(() => {
setBoundThreadId(node.threadId); setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId; boundThreadIdRef.current = node.threadId;
@@ -331,6 +305,17 @@ export function PositionNodeChat({
const activeThreadId = const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current; boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: filepath.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
const hasMultipleSwitchOptions = switchOptions.length > 1; const hasMultipleSwitchOptions = switchOptions.length > 1;
const switchSelectOptions = useMemo( const switchSelectOptions = useMemo(
() => () =>
@@ -341,14 +326,15 @@ export function PositionNodeChat({
option.agentName || optionNode.agentId || `智能体 ${index + 1}`, option.agentName || optionNode.agentId || `智能体 ${index + 1}`,
); );
const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : ""; const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : "";
return { label: `${optionName} · ${statusLabel}${artifactText}`, value: optionNode.nodeKey };
return {
label: `${optionName} · ${statusLabel}${artifactText}`,
value: optionNode.nodeKey,
};
}), }),
[switchOptions], [switchOptions],
); );
const switchSelectStyle = useMemo(() => ({ width: 380, maxWidth: "34vw" }), []);
const switchSelectPopupProps = useMemo(
() => ({ overlayInnerStyle: { minWidth: 380 } }),
[],
);
const handleSwitchNode = useCallback( const handleSwitchNode = useCallback(
(targetNode: PositionRoundtableNode) => { (targetNode: PositionRoundtableNode) => {
if (targetNode.nodeKey === node.nodeKey) return; if (targetNode.nodeKey === node.nodeKey) return;
@@ -360,7 +346,13 @@ export function PositionNodeChat({
}, },
[node.nodeKey, onSelectPositionNode, thread.isLoading], [node.nodeKey, onSelectPositionNode, thread.isLoading],
); );
const handleSwitchSelectChange = useCallback(
(value: unknown) => {
const nextNode = switchOptions.find((option) => option.node.nodeKey === String(value))?.node;
if (nextNode) handleSwitchNode(nextNode);
},
[handleSwitchNode, switchOptions],
);
useEffect(() => { useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread }); onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]); }, [activeThreadId, onThreadSnapshot, thread]);
@@ -465,30 +457,40 @@ export function PositionNodeChat({
</div> </div>
{hasMultipleSwitchOptions && ( {hasMultipleSwitchOptions && (
<div <div
className="ml-auto mr-12 flex min-w-0 shrink-0 items-center gap-2" className="ml-auto flex min-w-0 shrink-0 items-center gap-2"
title={thread.isLoading ? "当前智能体正在生成,完成后可切换" : "切换同岗位智能体"} title={thread.isLoading ? "当前智能体正在生成,完成后可切换" : "切换同岗位智能体"}
> >
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground xl:inline"> <span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
同岗位智能体 同岗位智能体
</span> </span>
<TSelect {thread.isLoading ? (
aria-label="同岗位智能体切换" <div
className="shrink-0 [&_.t-input]:!rounded-xl [&_.t-input]:!border-slate-200 [&_.t-input]:!bg-white [&_.t-input]:!text-xs [&_.t-input]:!font-semibold [&_.t-input]:!text-slate-700 [&_.t-input]:!shadow-sm dark:[&_.t-input]:!border-[#2b8ffe]/35 dark:[&_.t-input]:!bg-[#082d5f] dark:[&_.t-input]:!text-[#e8edf5]" className="flex h-9 shrink-0 items-center rounded-xl border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]"
style={{ width: 380, maxWidth: "34vw" }} style={switchSelectStyle}
value={node.nodeKey} >
options={switchSelectOptions} {agentDisplayName} · 进行中
onChange={(value) => { </div>
const nextNode = switchOptions.find( ) : (
(option) => option.node.nodeKey === String(value), <TSelect
)?.node; aria-label="同岗位智能体切换"
if (nextNode) { className="shrink-0 [&_.t-input]:!rounded-xl [&_.t-input]:!border-slate-200 [&_.t-input]:!bg-white [&_.t-input]:!text-xs [&_.t-input]:!font-semibold [&_.t-input]:!text-slate-700 [&_.t-input]:!shadow-sm dark:[&_.t-input]:!border-[#2b8ffe]/35 dark:[&_.t-input]:!bg-[#082d5f] dark:[&_.t-input]:!text-[#e8edf5]"
handleSwitchNode(nextNode); style={switchSelectStyle}
} value={node.nodeKey}
}} options={switchSelectOptions}
popupProps={{ overlayInnerStyle: { minWidth: 380 } }} onChange={handleSwitchSelectChange}
/> popupProps={switchSelectPopupProps}
/>
)}
</div> </div>
)} )}
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={null}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
</header> </header>
{nodeArtifacts.length > 0 && ( {nodeArtifacts.length > 0 && (
@@ -534,6 +536,7 @@ export function PositionNodeChat({
paddingBottom={24} paddingBottom={24}
scrollButtonClassName="bottom-6" scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/> />
) : ( ) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center"> <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
File diff suppressed because it is too large. Load diff
@@ -90,6 +90,10 @@ export interface Step1PanelProps {
onEnterRoundtable: () => void; onEnterRoundtable: () => void;
/** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */ /** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */
enterButtonLabel?: string; enterButtonLabel?: string;
/** 用于在意图确认后承接同一工作流的下一步动作。 */
footerAction?: React.ReactNode;
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
hideEnterRoundtable?: boolean;
onManualSave: () => void; onManualSave: () => void;
onTriggerToast: (msg: string) => void; onTriggerToast: (msg: string) => void;
submitIntentClarifications: ( submitIntentClarifications: (
@@ -116,6 +120,8 @@ export function Step1Panel(props: Step1PanelProps) {
onFollowUp, onFollowUp,
onEnterRoundtable, onEnterRoundtable,
enterButtonLabel = "进入多智能体研讨 >", enterButtonLabel = "进入多智能体研讨 >",
footerAction,
hideEnterRoundtable = false,
onManualSave, onManualSave,
onTriggerToast, onTriggerToast,
submitIntentClarifications, submitIntentClarifications,
@@ -330,20 +336,24 @@ export function Step1Panel(props: Step1PanelProps) {
跳过此项 跳过此项
</button> </button>
<button {footerAction ??
id="enter-roundtable-btn" (!hideEnterRoundtable && (
onClick={onEnterRoundtable} <button
disabled={!intentReady} id="enter-roundtable-btn"
title={intentReady ? undefined : "请先完成任务意图澄清"} type="button"
className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${ onClick={onEnterRoundtable}
intentReady disabled={!intentReady}
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer" title={intentReady ? undefined : "请先完成任务意图澄清"}
: "bg-emerald-600/40 text-white cursor-not-allowed" className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
}`} intentReady
> ? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
<Sparkles className="w-3.5 h-3.5 text-emerald-100" /> : "bg-emerald-600/40 text-white cursor-not-allowed"
{enterButtonLabel} }`}
</button> >
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
{enterButtonLabel}
</button>
))}
</div> </div>
<button <button