feat: refine position roundtable workflow
This commit is contained in:
1 parent
359bd28f2c
commit
13bfa719ee
12 files changed
+1749
-369
No files matched your search
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user