feat: refine position roundtable workflow
This commit is contained in:
1 parent
359bd28f2c
commit
13bfa719ee
12 files changed
+1838
-458
No files matched your search
@@ -71,15 +71,17 @@ const DialogOverlay = React.forwardRef<
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -121,13 +121,14 @@ export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
hideFileName = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
@@ -140,9 +141,10 @@ export function ArtifactFileDetail({
|
||||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||||
* 默认 false,沿用全局脱敏。
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
}) {
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
hideFileName?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
@@ -216,10 +218,11 @@ export function ArtifactFileDetail({
|
||||
!isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isAutoPreviewLanguage = language === "html" || language === "markdown";
|
||||
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
if (isSpreadsheetFile) return "XLSX";
|
||||
@@ -258,28 +261,31 @@ export function ArtifactFileDetail({
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setShowPreviewSwitchPulse(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreviewStreaming) {
|
||||
setViewMode("code");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSupportPreview) {
|
||||
setViewMode("preview");
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowAutoViewSwitch || language !== "html") {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [isPreviewStreaming, isSupportPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (thread.isLoading) {
|
||||
setViewMode("code");
|
||||
@@ -291,9 +297,9 @@ export function ArtifactFileDetail({
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowPreviewSwitchPulse(false);
|
||||
}, 1400);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [allowAutoViewSwitch, language, thread.isLoading]);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
|
||||
|
||||
const scrollArtifactToBottom = useCallback(
|
||||
(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">
|
||||
{fileTypeLabel}
|
||||
</span>
|
||||
<ArtifactTitle className="min-w-0">
|
||||
{useWriteFileStreaming ? (
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
) : hasMultipleArtifacts ? (
|
||||
<Select value={filepath} onValueChange={select}>
|
||||
<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">
|
||||
<SelectValue placeholder="Select a file" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="select-none">
|
||||
<SelectGroup>
|
||||
{(artifacts ?? []).map((artifactPath) => (
|
||||
<SelectItem key={artifactPath} value={artifactPath}>
|
||||
{getFileName(artifactPath)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
)}
|
||||
</ArtifactTitle>
|
||||
{!hideFileName && (
|
||||
<ArtifactTitle className="min-w-0">
|
||||
{useWriteFileStreaming ? (
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
) : hasMultipleArtifacts ? (
|
||||
<Select value={filepath} onValueChange={select}>
|
||||
<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">
|
||||
<SelectValue placeholder="Select a file" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="select-none">
|
||||
<SelectGroup>
|
||||
{(artifacts ?? []).map((artifactPath) => (
|
||||
<SelectItem key={artifactPath} value={artifactPath}>
|
||||
{getFileName(artifactPath)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
)}
|
||||
</ArtifactTitle>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||||
@@ -634,10 +642,10 @@ export function ArtifactFileDetail({
|
||||
<EyeIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{isHtmlStreaming && (
|
||||
<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" />
|
||||
正在生成,完成后自动预览
|
||||
{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]">
|
||||
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
|
||||
正在生成,完成后自动预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,28 +23,34 @@ import { useThread } from "../messages/context";
|
||||
import { useArtifacts } from "./context";
|
||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||
|
||||
export function ArtifactFileList({
|
||||
className,
|
||||
files,
|
||||
threadId,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
threadId: string;
|
||||
}) {
|
||||
export function ArtifactFileList({
|
||||
className,
|
||||
files,
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
threadId: string;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installFile, setInstallFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(filepath: string) => {
|
||||
selectArtifact(filepath);
|
||||
setOpen(true);
|
||||
},
|
||||
[selectArtifact, setOpen],
|
||||
);
|
||||
const handleClick = useCallback(
|
||||
(filepath: string) => {
|
||||
if (onOpenArtifact) {
|
||||
onOpenArtifact(filepath);
|
||||
return;
|
||||
}
|
||||
selectArtifact(filepath);
|
||||
setOpen(true);
|
||||
},
|
||||
[onOpenArtifact, selectArtifact, setOpen],
|
||||
);
|
||||
|
||||
const handleInstallSkill = useCallback(
|
||||
(e: React.MouseEvent, filepath: string) => {
|
||||
|
||||
@@ -101,10 +101,11 @@ export function MessageList({
|
||||
onEditResubmit,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
}: {
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -119,11 +120,12 @@ export function MessageList({
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (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 rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
@@ -488,7 +490,11 @@ export function MessageList({
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList files={files} threadId={threadId} />
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
|
||||
@@ -19,12 +19,32 @@ export async function loadArtifactContent({
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
return { content: text, url };
|
||||
}
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
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 };
|
||||
}
|
||||
|
||||
export function loadArtifactContentFromToolCall({
|
||||
url: urlString,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import type { AgentThread } from "../threads";
|
||||
|
||||
export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
@@ -16,22 +16,24 @@ export function urlOfArtifact({
|
||||
}) {
|
||||
// A public read-only share has no auth-gated thread route — its referenced
|
||||
// files are served by the unauthenticated public-share endpoint instead.
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||
return thread.values.artifacts ?? [];
|
||||
}
|
||||
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
||||
}
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
|
||||
}
|
||||
@@ -45,11 +45,45 @@ export type ThreadStreamOptions = {
|
||||
|
||||
type SendMessageOptions = {
|
||||
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"> & {
|
||||
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 {
|
||||
if (!runId) {
|
||||
@@ -965,27 +999,44 @@ export function useThreadStream({
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
||||
const submittedMessages: Message[] = [
|
||||
...hiddenContextMessages.map((item) => ({
|
||||
type: "human" as const,
|
||||
...(item.name ? { name: item.name } : {}),
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: item.text,
|
||||
},
|
||||
],
|
||||
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,
|
||||
streamSubgraphs: true,
|
||||
|
||||
@@ -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 { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
@@ -22,6 +21,11 @@ import {
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import {
|
||||
PositionBuiltinAgentTabs,
|
||||
type PositionBuiltinAgentKind,
|
||||
type PositionBuiltinAgentSwitchOption,
|
||||
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
||||
|
||||
interface PositionNodeSwitchOption {
|
||||
node: PositionRoundtableNode;
|
||||
@@ -39,6 +43,8 @@ interface PositionNodeChatProps {
|
||||
agentName?: string | null;
|
||||
positionNodeOptions?: PositionNodeSwitchOption[];
|
||||
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
|
||||
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
|
||||
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
|
||||
onNodeUpdate: (node: PositionRoundtableNode) => void;
|
||||
/** The workspace collapses the configuration panel on the first real turn. */
|
||||
onConversationStart?: () => void;
|
||||
@@ -108,10 +114,7 @@ function hasAnyArtifact(manifest: Array<Record<string, unknown>>): boolean {
|
||||
}
|
||||
|
||||
function nodeSwitchStatusLabel(node: PositionRoundtableNode): string {
|
||||
if (node.invalidatedBy || node.status === "stale") {
|
||||
return "需重做";
|
||||
}
|
||||
|
||||
if (node.invalidatedBy || node.status === "stale") return "需重做";
|
||||
switch (node.status) {
|
||||
case "running":
|
||||
return "进行中";
|
||||
@@ -193,6 +196,8 @@ export function PositionNodeChat({
|
||||
agentName,
|
||||
positionNodeOptions,
|
||||
onSelectPositionNode,
|
||||
builtinAgentOptions,
|
||||
onSelectBuiltinAgent,
|
||||
onNodeUpdate,
|
||||
onConversationStart,
|
||||
onPreviewArtifact,
|
||||
@@ -208,16 +213,10 @@ export function PositionNodeChat({
|
||||
: [{ node, agentName }],
|
||||
[agentName, node, positionNodeOptions],
|
||||
);
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||
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 nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
|
||||
const canWrite =
|
||||
@@ -227,31 +226,6 @@ export function PositionNodeChat({
|
||||
node.status !== "error" &&
|
||||
!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(() => {
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
@@ -331,6 +305,17 @@ export function PositionNodeChat({
|
||||
|
||||
const activeThreadId =
|
||||
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 switchSelectOptions = useMemo(
|
||||
() =>
|
||||
@@ -341,14 +326,15 @@ export function PositionNodeChat({
|
||||
option.agentName || optionNode.agentId || `智能体 ${index + 1}`,
|
||||
);
|
||||
const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : "";
|
||||
|
||||
return {
|
||||
label: `${optionName} · ${statusLabel}${artifactText}`,
|
||||
value: optionNode.nodeKey,
|
||||
};
|
||||
return { label: `${optionName} · ${statusLabel}${artifactText}`, value: optionNode.nodeKey };
|
||||
}),
|
||||
[switchOptions],
|
||||
);
|
||||
const switchSelectStyle = useMemo(() => ({ width: 380, maxWidth: "34vw" }), []);
|
||||
const switchSelectPopupProps = useMemo(
|
||||
() => ({ overlayInnerStyle: { minWidth: 380 } }),
|
||||
[],
|
||||
);
|
||||
const handleSwitchNode = useCallback(
|
||||
(targetNode: PositionRoundtableNode) => {
|
||||
if (targetNode.nodeKey === node.nodeKey) return;
|
||||
@@ -360,7 +346,13 @@ export function PositionNodeChat({
|
||||
},
|
||||
[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(() => {
|
||||
onThreadSnapshot?.({ threadId: activeThreadId, thread });
|
||||
}, [activeThreadId, onThreadSnapshot, thread]);
|
||||
@@ -465,30 +457,40 @@ export function PositionNodeChat({
|
||||
</div>
|
||||
{hasMultipleSwitchOptions && (
|
||||
<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 ? "当前智能体正在生成,完成后可切换" : "切换同岗位智能体"}
|
||||
>
|
||||
<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>
|
||||
<TSelect
|
||||
aria-label="同岗位智能体切换"
|
||||
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]"
|
||||
style={{ width: 380, maxWidth: "34vw" }}
|
||||
value={node.nodeKey}
|
||||
options={switchSelectOptions}
|
||||
onChange={(value) => {
|
||||
const nextNode = switchOptions.find(
|
||||
(option) => option.node.nodeKey === String(value),
|
||||
)?.node;
|
||||
if (nextNode) {
|
||||
handleSwitchNode(nextNode);
|
||||
}
|
||||
}}
|
||||
popupProps={{ overlayInnerStyle: { minWidth: 380 } }}
|
||||
/>
|
||||
{thread.isLoading ? (
|
||||
<div
|
||||
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={switchSelectStyle}
|
||||
>
|
||||
{agentDisplayName} · 进行中
|
||||
</div>
|
||||
) : (
|
||||
<TSelect
|
||||
aria-label="同岗位智能体切换"
|
||||
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]"
|
||||
style={switchSelectStyle}
|
||||
value={node.nodeKey}
|
||||
options={switchSelectOptions}
|
||||
onChange={handleSwitchSelectChange}
|
||||
popupProps={switchSelectPopupProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{builtinAgentOptions && onSelectBuiltinAgent && (
|
||||
<PositionBuiltinAgentTabs
|
||||
activeKind={null}
|
||||
options={builtinAgentOptions}
|
||||
isStreaming={thread.isLoading}
|
||||
onSelect={onSelectBuiltinAgent}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{nodeArtifacts.length > 0 && (
|
||||
@@ -534,6 +536,7 @@ export function PositionNodeChat({
|
||||
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">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -90,6 +90,10 @@ export interface Step1PanelProps {
|
||||
onEnterRoundtable: () => void;
|
||||
/** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */
|
||||
enterButtonLabel?: string;
|
||||
/** 用于在意图确认后承接同一工作流的下一步动作。 */
|
||||
footerAction?: React.ReactNode;
|
||||
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
|
||||
hideEnterRoundtable?: boolean;
|
||||
onManualSave: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
submitIntentClarifications: (
|
||||
@@ -116,6 +120,8 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
onFollowUp,
|
||||
onEnterRoundtable,
|
||||
enterButtonLabel = "进入多智能体研讨 >",
|
||||
footerAction,
|
||||
hideEnterRoundtable = false,
|
||||
onManualSave,
|
||||
onTriggerToast,
|
||||
submitIntentClarifications,
|
||||
@@ -330,20 +336,24 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
跳过此项
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="enter-roundtable-btn"
|
||||
onClick={onEnterRoundtable}
|
||||
disabled={!intentReady}
|
||||
title={intentReady ? undefined : "请先完成任务意图澄清"}
|
||||
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"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
{enterButtonLabel}
|
||||
</button>
|
||||
{footerAction ??
|
||||
(!hideEnterRoundtable && (
|
||||
<button
|
||||
id="enter-roundtable-btn"
|
||||
type="button"
|
||||
onClick={onEnterRoundtable}
|
||||
disabled={!intentReady}
|
||||
title={intentReady ? undefined : "请先完成任务意图澄清"}
|
||||
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"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
{enterButtonLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user