Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
0b98d18483
43 files changed
+1838
-432
No files matched your search
@@ -22,6 +22,7 @@ import PublicSharePage from "./pages/PublicSharePage";
|
||||
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
@@ -96,6 +97,8 @@ export function App() {
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */}
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="62" viewBox="0 0 1920 62"><defs><style>.a{fill:url(#a);}.b{fill:rgba(0,98,217,0.1);}.c{fill:#fff;}.d{fill:rgba(0,98,217,0.2);}.e{filter:url(#h);}.f{filter:url(#e);}.g{filter:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0b3776"/><stop offset="1" stop-color="#0c1935"/></linearGradient><filter id="b"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="c"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="d"/><feComposite operator="out" in="SourceGraphic" in2="c"/><feComposite operator="in" in="d"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="e"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="f"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="g"/><feComposite operator="out" in="SourceGraphic" in2="f"/><feComposite operator="in" in="g"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="h"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="i"/><feFlood flood-color="#0062d9" flood-opacity="0.2" result="j"/><feComposite operator="out" in="SourceGraphic" in2="i"/><feComposite operator="in" in="j"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(-2009 -3361)"><rect class="a" width="1920" height="62" transform="translate(2009 3361)"/><g data-type="innerShadowGroup"><path class="b" d="M0,0H318L268,62H0Z" transform="translate(2009 3361)"/><g class="g" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H318L268,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="b" d="M0,0H310L260,62H0Z" transform="translate(2009 3361)"/><g class="f" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H310L260,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="d" d="M0,0H302L252,62H0Z" transform="translate(2009 3361)"/><g class="e" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H302L252,62H0Z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -4,28 +4,38 @@ import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
modal,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
// 无界嵌入时 Radix modal 会给 body 注入异常 margin-right,导致右侧空白。
|
||||
// 默认降为 non-modal;显式传 modal={true} 仍可强制 modal。
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
modal={modal ?? !isWujieEnv()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
@@ -33,68 +43,68 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
DialogClose.displayName = DialogPrimitive.Close.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 />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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 />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -31,11 +31,29 @@ export interface WujieProps {
|
||||
/** 嵌入问答:DeerFlow 本地用户名登录。 */
|
||||
username?: string;
|
||||
userName?: string;
|
||||
/** 用户名+密码登录的共享口令(后端开启 username_login.require_password 时校验)。 */
|
||||
password?: string;
|
||||
/** 圆桌规划嵌入:任务名称(注入 Step 1 首条用户消息)。 */
|
||||
taskTitle?: string;
|
||||
/** 圆桌规划嵌入:任务描述(注入 Step 1 首条用户消息)。 */
|
||||
taskGoal?: string;
|
||||
/** 圆桌规划嵌入:指定业务链条 id(直传,跳过业务映射表)。 */
|
||||
chainId?: string;
|
||||
/** 圆桌规划嵌入:业务码(可选回退;后端业务映射表查 chainId)。 */
|
||||
businessCode?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 无界跨实例事件总线(子→父 / 父→子 通信)。 */
|
||||
interface WujieBus {
|
||||
$emit?: (event: string, ...args: unknown[]) => void;
|
||||
$on?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
$off?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
interface WujieRuntime {
|
||||
props?: WujieProps;
|
||||
bus?: WujieBus;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -59,6 +77,33 @@ export function readWujieProps(): WujieProps {
|
||||
return window.$wujie?.props ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 子→父:通过无界事件总线广播一个事件。父页面用 `bus.$on(event, handler)` 监听。
|
||||
* 非无界环境 / 总线缺失时静默忽略(返回 false),可安全无条件调用。
|
||||
*/
|
||||
export function emitWujieEvent(event: string, payload?: unknown): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const emit = window.$wujie?.bus?.$emit;
|
||||
if (typeof emit !== "function") return false;
|
||||
emit(event, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 父→子:订阅无界事件总线上的一个事件。返回取消订阅函数(在 effect cleanup 里调用)。
|
||||
* 非无界环境 / 总线缺失时返回 no-op,可安全无条件调用。
|
||||
*/
|
||||
export function onWujieEvent(
|
||||
event: string,
|
||||
handler: (...args: unknown[]) => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const bus = window.$wujie?.bus;
|
||||
if (typeof bus?.$on !== "function") return () => {};
|
||||
bus.$on(event, handler);
|
||||
return () => bus.$off?.(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局读取无界父级注入的 props 的 hook。
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PenLine } from 'lucide-react';
|
||||
import { PenLine, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -176,17 +176,19 @@ export function AIWritingForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
|
||||
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white [&_h3]:dark:text-white [&_label]:dark:text-white">
|
||||
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]">
|
||||
<PenLine className="size-4 text-[#8b6f6f] dark:text-white" />
|
||||
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f] dark:text-white">
|
||||
<div className="rounded-2xl border border-[#ece5e4] bg-card p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white [&_h3]:dark:text-white [&_label]:dark:text-white">
|
||||
<div className="flex items-center gap-2.5 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]">
|
||||
<SlidersHorizontal className="size-4 text-primary dark:text-white" />
|
||||
<h3 className="text-[18px] font-semibold leading-none tracking-tight text-[#2f2f2f] dark:text-white">
|
||||
写作配置选项
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={FIELD_LABEL_CLASS}>写作意图</label>
|
||||
<label className={FIELD_LABEL_CLASS}>
|
||||
写作意图 <span className="text-primary dark:text-white">*</span>
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground dark:text-white/70">
|
||||
{intent.length} / {MAX_INTENT_LENGTH} 字
|
||||
</span>
|
||||
@@ -384,7 +386,7 @@ export function AIWritingForm({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
className="mt-4 h-12 w-full gap-2 rounded-2xl text-base font-semibold"
|
||||
className="mt-5 h-12 w-full gap-2 rounded-2xl text-base font-semibold shadow-lg shadow-primary/20 transition-all hover:shadow-primary/30 active:scale-[0.98]"
|
||||
>
|
||||
<PenLine className="size-4" />
|
||||
开始 AI 写作
|
||||
|
||||
@@ -29,6 +29,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||
import { WritingWelcome } from './WritingWelcome';
|
||||
|
||||
/**
|
||||
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
|
||||
@@ -103,6 +104,9 @@ export function WritingSetupChat() {
|
||||
[applyProposal],
|
||||
);
|
||||
|
||||
// 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致)
|
||||
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
|
||||
}, [handleChatSubmit]);
|
||||
@@ -111,58 +115,74 @@ export function WritingSetupChat() {
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex size-full flex-col bg-background">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">写作助手</h2>
|
||||
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span>
|
||||
</header>
|
||||
{/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
|
||||
{!showWelcome && (
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">写作助手</h2>
|
||||
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
onSetupWritingApprove={handleApprove}
|
||||
onSetupWritingReject={handleReject}
|
||||
/>
|
||||
{showWelcome ? (
|
||||
<WritingWelcome />
|
||||
) : (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
onSetupWritingApprove={handleApprove}
|
||||
onSetupWritingReject={handleReject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">当前对话模型</span>
|
||||
{modelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{modelsLoading ? '加载中…' : '暂无模型'}
|
||||
<div className="bg-background shrink-0 border-t px-4 py-4">
|
||||
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
|
||||
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="shrink-0">当前对话模型</span>
|
||||
{modelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[9rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{modelsLoading ? '加载中…' : '暂无模型'}
|
||||
</div>
|
||||
) : (
|
||||
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'border-input bg-background h-7 min-w-[9rem] max-w-[14rem] px-2 text-xs shadow-xs outline-none',
|
||||
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none',
|
||||
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<span className="hidden shrink-0 text-[11px] text-muted-foreground/70 sm:block">
|
||||
输入想法,按 Enter 直接解析填充
|
||||
</span>
|
||||
</div>
|
||||
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
|
||||
<PromptInputTextarea
|
||||
placeholder="例如:帮我写一篇 1500 字、面向行业专家关于人工智能商业趋势的分析报道"
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit status={submitStatus} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
|
||||
<PromptInputTextarea
|
||||
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit status={submitStatus} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { PenTool, Sliders, Sparkles } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* AI 写作入口页左栏「欢迎/空状态」屏。
|
||||
*
|
||||
* 仅在写作助手对话还没有任何消息时显示(见 WritingSetupChat)。一旦用户在底栏发出
|
||||
* 第一条消息,就回到正常的对话时间线。
|
||||
*/
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
n: '1',
|
||||
title: '对白意图解析',
|
||||
desc: '在底栏用人话随便说出你的想法,AI 会瞬间解析要求并填充右侧配置。',
|
||||
icon: Sparkles,
|
||||
iconClass: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300',
|
||||
},
|
||||
{
|
||||
n: '2',
|
||||
title: '参数多端对齐',
|
||||
desc: '右侧配置框即刻对齐所需的体裁、目标行文大纲和字数限制。',
|
||||
icon: Sliders,
|
||||
iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300',
|
||||
},
|
||||
{
|
||||
n: '3',
|
||||
title: '深度排版落成',
|
||||
desc: '点击右侧红色创作按钮,精美的行业或创意文档极速铺开。',
|
||||
icon: PenTool,
|
||||
iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300',
|
||||
},
|
||||
];
|
||||
|
||||
export function WritingWelcome() {
|
||||
return (
|
||||
<div className="size-full overflow-y-auto">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
|
||||
{/* 标题 + 介绍 + 三步引导卡片:在区域里垂直居中 */}
|
||||
<div className="flex flex-1 flex-col justify-center gap-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
||||
欢迎使用「妙笔 AI」智力打字协作工坊
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||
这是一个专业的自动化写作系统。你只需在下方对话框中
|
||||
<strong className="font-semibold text-slate-700 dark:text-white/80">
|
||||
输入你的本意(例如:「帮我写一篇 1500 字的新能源汽车报告」)
|
||||
</strong>
|
||||
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step) => (
|
||||
<div
|
||||
key={step.n}
|
||||
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
|
||||
>
|
||||
<div className={`flex size-8 items-center justify-center rounded-lg ${step.iconClass}`}>
|
||||
<step.icon className="size-4" />
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-800 dark:text-white">
|
||||
{step.n}. {step.title}
|
||||
</h4>
|
||||
<p className="text-xs leading-relaxed text-slate-500 dark:text-white/55">{step.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import { readWujieProps, isWujieEnv } from "@/core/embed/wujie";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
/**
|
||||
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
|
||||
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
|
||||
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
|
||||
*/
|
||||
function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "light" || v === "red" || v === "红白") return "light";
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。
|
||||
*
|
||||
* 用于宿主(如 parallel-agents-vue 的 RoundtableDrawer)用无界把圆桌**主体页面**塞进抽屉:
|
||||
* - **登录**:走「用户名免密登录」(`loginByUsername`),username 来自 `window.$wujie.props.username`
|
||||
* (或 URL `?username=`),不经 LoginPage 的 MaxKey/consumer ticket 流程。
|
||||
* - **无侧边栏**:本路由挂在顶层(`/embed/roundtable`),在 `AppProviders` 之下、`PageRoutes` 之外,
|
||||
* 天然不含页面侧边栏 / workspace 导航,只渲染圆桌主体。
|
||||
* - **任务预填**:taskTitle / taskGoal / chainId 由 `RoundtablePlanningPage` 自身的
|
||||
* `readWujiePrefill()` 直接从 `window.$wujie.props` 读取(抽屉打开即自动跑第一步、按链条进第二步)。
|
||||
*/
|
||||
export default function EmbedRoundtablePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// username / password / theme:wujie props 优先,URL query 兜底。
|
||||
const wp = readWujieProps();
|
||||
const username =
|
||||
(typeof wp.username === "string" && wp.username.trim()) ||
|
||||
(typeof wp.userName === "string" && wp.userName.trim()) ||
|
||||
searchParams.get("username")?.trim() ||
|
||||
searchParams.get("userName")?.trim() ||
|
||||
DEFAULT_USERNAME;
|
||||
// 共享口令(用户名+密码登录模式):仅当后端开启 username_login.require_password 时校验。
|
||||
// 不 trim —— 口令可能含首尾空格;未传则按「免密」走(后端未开门禁时照常)。
|
||||
const password =
|
||||
(typeof wp.password === "string" && wp.password) ||
|
||||
searchParams.get("password") ||
|
||||
undefined;
|
||||
// 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。
|
||||
const themeParam =
|
||||
(typeof wp.theme === "string" && wp.theme.trim()) ||
|
||||
searchParams.get("theme")?.trim() ||
|
||||
"dark";
|
||||
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(resolveEmbedRoundtableTheme(themeParam));
|
||||
}, [themeParam, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWujieEnv()) return;
|
||||
document.documentElement.classList.add("wujie-embed");
|
||||
return () => document.documentElement.classList.remove("wujie-embed");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function runLogin() {
|
||||
setAuthError(null);
|
||||
try {
|
||||
const auth = await loginByUsername(username, password);
|
||||
if (cancelled) return;
|
||||
setStoredAuth(auth);
|
||||
setAuthReady(true);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setAuthError(error instanceof Error ? error.message : "登录失败");
|
||||
}
|
||||
}
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [username, password]);
|
||||
|
||||
if (authError) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
|
||||
登录失败:{authError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authReady) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
|
||||
正在进入圆桌…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RoundtablePlanningPage 依赖 SubtasksProvider / ArtifactsProvider / PromptInputProvider
|
||||
// (Step 2 沙箱用 useArtifacts)。ChatRuntime 正是这三者的封装;不套 WorkspaceLayout,
|
||||
// 故无侧边栏,只渲染圆桌主体。
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,6 +116,8 @@ export interface DraftStep3Snapshot {
|
||||
/** Lightweight metadata row returned by the list endpoint. */
|
||||
export interface DraftMeta {
|
||||
id: string;
|
||||
/** 外部任务 id(无界嵌入抽屉传入);非空时草稿按 task_id 共享、不按 user 分权。 */
|
||||
taskId?: string | null;
|
||||
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||
title: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
@@ -141,6 +143,8 @@ export interface DraftRecord extends DraftMeta {
|
||||
export interface DraftCreatePayload {
|
||||
/** Optional stable id so the client can keep one id across create + updates. */
|
||||
id?: string;
|
||||
/** 外部任务 id(无界嵌入抽屉);设置后草稿按 task_id 共享、不按 user 分权。 */
|
||||
taskId?: string | null;
|
||||
title: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
step1: DraftStep1Snapshot;
|
||||
@@ -152,6 +156,8 @@ export interface DraftCreatePayload {
|
||||
export interface DraftUpdatePayload {
|
||||
title?: string;
|
||||
furthestStep?: 1 | 2 | 3;
|
||||
/** 给已有草稿补绑外部任务 id。 */
|
||||
taskId?: string | null;
|
||||
step1?: DraftStep1Snapshot;
|
||||
step2?: DraftStep2Snapshot;
|
||||
step3?: DraftStep3Snapshot | null;
|
||||
@@ -173,6 +179,7 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
|
||||
interface ServerDraftMeta {
|
||||
id: string;
|
||||
task_id?: string | null;
|
||||
title: string;
|
||||
furthest_step: number;
|
||||
created_at: string | null;
|
||||
@@ -191,6 +198,7 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
|
||||
const step = Math.min(3, Math.max(1, s.furthest_step || 1)) as 1 | 2 | 3;
|
||||
return {
|
||||
id: s.id,
|
||||
taskId: s.task_id ?? null,
|
||||
title: s.title ?? "",
|
||||
furthestStep: step,
|
||||
createdAt: s.created_at ?? "",
|
||||
@@ -212,6 +220,7 @@ function toRecord(s: ServerDraftFull): DraftRecord {
|
||||
function fromCreate(p: DraftCreatePayload) {
|
||||
return {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
...(p.taskId ? { task_id: p.taskId } : {}),
|
||||
title: p.title,
|
||||
furthest_step: p.furthestStep,
|
||||
step1: p.step1,
|
||||
@@ -224,6 +233,7 @@ function fromUpdate(p: DraftUpdatePayload) {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.title !== undefined) out.title = p.title;
|
||||
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
|
||||
if (p.taskId !== undefined) out.task_id = p.taskId;
|
||||
if (p.step1 !== undefined) out.step1 = p.step1;
|
||||
if (p.step2 !== undefined) out.step2 = p.step2;
|
||||
if (p.step3 !== undefined) out.step3 = p.step3;
|
||||
@@ -247,6 +257,17 @@ export async function getDraft(id: string): Promise<DraftRecord | null> {
|
||||
return toRecord(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按外部任务 id 取最新一条草稿(后端忽略 user,不分权)。无则返回 null(404)。
|
||||
* 无界嵌入抽屉打开时用于「该任务有聊天记录则回显最新一条」。
|
||||
*/
|
||||
export async function getLatestDraftByTask(taskId: string): Promise<DraftRecord | null> {
|
||||
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" });
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerDraftFull>(res, "get latest draft by task");
|
||||
return toRecord(data);
|
||||
}
|
||||
|
||||
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -197,7 +198,7 @@ export function Step3ReportMode({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={redraw}
|
||||
onClick={() => redraw()}
|
||||
disabled={phase === "creating" || phase === "streaming"}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
@@ -311,6 +312,7 @@ export function Step3ReportMode({
|
||||
dialogues={dialogues}
|
||||
error={error}
|
||||
onRetry={redraw}
|
||||
onRevisionSubmit={redraw}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
@@ -432,6 +434,7 @@ function AgentProgressPanel({
|
||||
dialogues,
|
||||
error,
|
||||
onRetry,
|
||||
onRevisionSubmit,
|
||||
onArtifactStepClick,
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
@@ -443,6 +446,7 @@ function AgentProgressPanel({
|
||||
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
onRevisionSubmit: (revisionInstruction: string) => void;
|
||||
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
|
||||
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
@@ -453,6 +457,15 @@ function AgentProgressPanel({
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
|
||||
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const text = revisionText.trim();
|
||||
if (!text || busy) return;
|
||||
setRevisionText("");
|
||||
onRevisionSubmit(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
|
||||
@@ -549,6 +562,39 @@ function AgentProgressPanel({
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
<form
|
||||
onSubmit={handleRevisionSubmit}
|
||||
className="shrink-0 border-t border-slate-100 bg-white px-3 py-3"
|
||||
>
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
|
||||
<textarea
|
||||
value={revisionText}
|
||||
onChange={(e) => setRevisionText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
rows={1}
|
||||
placeholder="输入修改要求,例如:把右侧页面改成深色风格,并增加风险矩阵"
|
||||
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || revisionText.trim().length === 0}
|
||||
title={busy ? "正在绘制,请稍后发送" : "发送修改要求"}
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
getDraft,
|
||||
getLatestDraftByTask,
|
||||
listDrafts,
|
||||
updateDraft,
|
||||
type DraftRecord,
|
||||
type DraftStep2Snapshot,
|
||||
type DraftStep3Snapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
@@ -48,6 +50,11 @@ export interface UseDraftPersistenceOptions {
|
||||
currentStep: number;
|
||||
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
triggerToast: (msg: string) => void;
|
||||
/**
|
||||
* 外部任务 id(无界嵌入抽屉传入)。非空时:①新建草稿一律绑定该 task_id(按任务共享、
|
||||
* 不按 user 分权);②可用 `loadLatestByTask` 按任务回显最新聊天记录。
|
||||
*/
|
||||
taskId?: string | null;
|
||||
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
|
||||
getStep1Snapshot: () => Step1Snapshot;
|
||||
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
|
||||
@@ -97,6 +104,8 @@ export interface UseDraftPersistenceReturn {
|
||||
setCurrentDraftId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
handleManualSave: () => void;
|
||||
handleLoadDraft: (id: string) => void;
|
||||
/** 按外部任务 id 取最新草稿并 hydrate,返回是否命中(用于抑制 autoStart)。 */
|
||||
loadLatestByTask: (taskId: string) => Promise<boolean>;
|
||||
handleDeleteDraft: (id: string) => void;
|
||||
handleStartRename: (draft: DraftListItem) => void;
|
||||
handleCommitRename: (id: string) => void;
|
||||
@@ -117,6 +126,7 @@ export function useDraftPersistence(
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
triggerToast,
|
||||
taskId,
|
||||
getStep1Snapshot,
|
||||
getStep2Snapshot,
|
||||
getStep3Snapshot,
|
||||
@@ -130,6 +140,12 @@ export function useDraftPersistence(
|
||||
step3SavedTick = 0,
|
||||
} = options;
|
||||
|
||||
// 同步镜像 taskId,供保存队列在 render 之前读到最新值。
|
||||
const taskIdRef = useRef<string | null>(taskId ?? null);
|
||||
useEffect(() => {
|
||||
taskIdRef.current = taskId ?? null;
|
||||
}, [taskId]);
|
||||
|
||||
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
|
||||
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
|
||||
@@ -224,6 +240,7 @@ export function useDraftPersistence(
|
||||
// 服务端已不存在(被删/换账号)→ 落回新建。
|
||||
}
|
||||
const created = await createDraft({
|
||||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||||
title: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
@@ -324,6 +341,46 @@ export function useDraftPersistence(
|
||||
if (step3SavedTick > prev) void autoSave();
|
||||
}, [step3SavedTick, autoSave]);
|
||||
|
||||
/** 把一份已取到的草稿 hydrate 进各步骤状态(handleLoadDraft / loadLatestByTask 共用)。 */
|
||||
const applyLoadedDraft = useCallback(
|
||||
(draft: DraftRecord, toastMsg?: string) => {
|
||||
cancelAllStreams();
|
||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||
loadedFromDraftRef.current = true;
|
||||
// hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存,
|
||||
// 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。
|
||||
suppressStep2AutoSaveRef.current = true;
|
||||
|
||||
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
|
||||
if (draft.step2) {
|
||||
hydrateStep2(
|
||||
draft.step2 as DraftStep2Snapshot,
|
||||
(draft.step3 as DraftStep3Snapshot | null) ?? null,
|
||||
);
|
||||
}
|
||||
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
|
||||
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
|
||||
setCurrentDraftId(draft.id);
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
if (toastMsg) triggerToast(toastMsg);
|
||||
},
|
||||
[
|
||||
cancelAllStreams,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
hydrateStep3,
|
||||
setCurrentDraftId,
|
||||
setCurrentStep,
|
||||
setStep2InitGate,
|
||||
triggerToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleLoadDraft = useCallback(
|
||||
(id: string) => {
|
||||
void (async () => {
|
||||
@@ -340,44 +397,30 @@ export function useDraftPersistence(
|
||||
await refreshList();
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAllStreams();
|
||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||
loadedFromDraftRef.current = true;
|
||||
// hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存,
|
||||
// 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。
|
||||
suppressStep2AutoSaveRef.current = true;
|
||||
|
||||
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
|
||||
if (draft.step2) {
|
||||
hydrateStep2(
|
||||
draft.step2 as DraftStep2Snapshot,
|
||||
(draft.step3 as DraftStep3Snapshot | null) ?? null,
|
||||
);
|
||||
}
|
||||
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
|
||||
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
|
||||
setCurrentDraftId(draft.id);
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.title}`);
|
||||
applyLoadedDraft(draft, `📂 已加载草稿:${draft.title}`);
|
||||
})();
|
||||
},
|
||||
[
|
||||
cancelAllStreams,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
hydrateStep3,
|
||||
refreshList,
|
||||
setCurrentDraftId,
|
||||
setCurrentStep,
|
||||
setStep2InitGate,
|
||||
triggerToast,
|
||||
],
|
||||
[applyLoadedDraft, refreshList, triggerToast],
|
||||
);
|
||||
|
||||
/**
|
||||
* 按外部任务 id 取最新一条草稿并 hydrate 回显(无界抽屉打开时调用)。
|
||||
* 返回是否命中(命中 → 调用方应抑制 autoStart,避免重复发首条消息)。
|
||||
*/
|
||||
const loadLatestByTask = useCallback(
|
||||
async (tid: string): Promise<boolean> => {
|
||||
let draft: DraftRecord | null = null;
|
||||
try {
|
||||
draft = await getLatestDraftByTask(tid);
|
||||
} catch (err) {
|
||||
console.warn("[drafts] 按任务加载草稿失败:", err);
|
||||
return false;
|
||||
}
|
||||
if (!draft) return false;
|
||||
applyLoadedDraft(draft);
|
||||
return true;
|
||||
},
|
||||
[applyLoadedDraft],
|
||||
);
|
||||
|
||||
const handleDeleteDraft = useCallback(
|
||||
@@ -466,6 +509,7 @@ export function useDraftPersistence(
|
||||
setCurrentDraftId,
|
||||
handleManualSave,
|
||||
handleLoadDraft,
|
||||
loadLatestByTask,
|
||||
handleDeleteDraft,
|
||||
handleStartRename,
|
||||
handleCommitRename,
|
||||
|
||||
@@ -72,11 +72,11 @@ export interface UseStep3ReportReturn {
|
||||
/** 当前沙箱预览绑定的 thread id(这里恒为报告线程)。 */
|
||||
activeArtifactThreadId: string | null;
|
||||
/** 启动绘制(幂等:进行中重复调用会被忽略)。 */
|
||||
start: () => void;
|
||||
start: (revisionInstruction?: string) => void;
|
||||
/** 从草稿快照恢复展示(建合成 stub + 气泡 + 打开沙箱,phase=done,不重新绘制)。 */
|
||||
hydrateSaved: (snapshot: DraftStep3Snapshot) => void;
|
||||
/** 重新绘制(先中断在途请求再重跑)。 */
|
||||
redraw: () => void;
|
||||
redraw: (revisionInstruction?: string) => void;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
|
||||
@@ -92,8 +92,10 @@ function buildReportPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
revisionInstruction?: string;
|
||||
previousHtml?: string;
|
||||
}): string {
|
||||
const { intentReady, seatDeliveries, consensusText } = opts;
|
||||
const { intentReady, seatDeliveries, consensusText, revisionInstruction, previousHtml } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," +
|
||||
@@ -135,6 +137,23 @@ function buildReportPrompt(opts: {
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
const cleanRevisionInstruction = revisionInstruction?.trim();
|
||||
if (cleanRevisionInstruction) {
|
||||
parts.push("# User revision request");
|
||||
parts.push(cleanRevisionInstruction);
|
||||
parts.push("");
|
||||
if (previousHtml?.trim()) {
|
||||
parts.push("# Previous rendered HTML to revise");
|
||||
parts.push(
|
||||
"Use this as the baseline. Preserve parts not contradicted by the revision request, and output a complete updated self-contained HTML file.",
|
||||
);
|
||||
parts.push("```html");
|
||||
parts.push(previousHtml.trim());
|
||||
parts.push("```");
|
||||
parts.push("");
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" +
|
||||
"(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" +
|
||||
@@ -173,7 +192,7 @@ function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
|
||||
export function useStep3Report(
|
||||
options: UseStep3ReportOptions,
|
||||
): UseStep3ReportReturn {
|
||||
const { intentReady, seatDeliveries, consensusText, selectedModel, onReportSaved } =
|
||||
const { intentReady, seatDeliveries, consensusText, selectedModel, savedReport, onReportSaved } =
|
||||
options;
|
||||
|
||||
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
|
||||
@@ -601,7 +620,7 @@ export function useStep3Report(
|
||||
runningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const run = useCallback(async (revisionInstruction?: string) => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
@@ -632,7 +651,13 @@ export function useStep3Report(
|
||||
);
|
||||
setPhase("streaming");
|
||||
|
||||
const prompt = buildReportPrompt({ intentReady, seatDeliveries, consensusText });
|
||||
const prompt = buildReportPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
revisionInstruction,
|
||||
previousHtml: savedReport?.html,
|
||||
});
|
||||
const res = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
agentName,
|
||||
@@ -687,6 +712,7 @@ export function useStep3Report(
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
savedReport?.html,
|
||||
selectedModel,
|
||||
appendStreamingDialogue,
|
||||
appendToDialogue,
|
||||
@@ -699,8 +725,8 @@ export function useStep3Report(
|
||||
reconcileStubFromRealFile,
|
||||
]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
void run();
|
||||
const start = useCallback((revisionInstruction?: string) => {
|
||||
void run(revisionInstruction);
|
||||
}, [run]);
|
||||
|
||||
// 从草稿快照恢复:不跑 SSE,用保存的 HTML 重建一条「完成」气泡 + 合成 stub,
|
||||
@@ -766,9 +792,9 @@ export function useStep3Report(
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const redraw = useCallback((revisionInstruction?: string) => {
|
||||
abort();
|
||||
void run();
|
||||
void run(revisionInstruction);
|
||||
}, [abort, run]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { collectRoundtableDeliverables } from "./roundtable-deliverables";
|
||||
import type { Step2Dialogue } from "../hooks/useStep2Orchestration";
|
||||
|
||||
/** 构造一条最小 Step2Dialogue(只填测试关心的字段)。 */
|
||||
function bubble(partial: Partial<Step2Dialogue> & { sender: string }): Step2Dialogue {
|
||||
return {
|
||||
id: partial.id ?? Math.floor(Math.random() * 1e6),
|
||||
sender: partial.sender,
|
||||
avatarType: partial.avatarType ?? "default",
|
||||
content: partial.content ?? "",
|
||||
time: partial.time ?? "00:00",
|
||||
confidence: partial.confidence ?? "",
|
||||
streaming: partial.streaming,
|
||||
agentThreadId: partial.agentThreadId,
|
||||
steps: partial.steps,
|
||||
};
|
||||
}
|
||||
|
||||
test("收集每个席位的交付,剔除总控", () => {
|
||||
const dialogues = [
|
||||
bubble({ sender: "总控", content: "派活中…" }),
|
||||
bubble({ sender: "方案设计", content: "方案A", agentThreadId: "t-a" }),
|
||||
bubble({ sender: "风险审查", content: "风险B", agentThreadId: "t-b" }),
|
||||
bubble({ sender: "总控", content: "已达成共识" }),
|
||||
];
|
||||
const result = collectRoundtableDeliverables(dialogues, "总控");
|
||||
assert.deepEqual(result, [
|
||||
{ agentName: "方案设计", content: "方案A", threadId: "t-a" },
|
||||
{ agentName: "风险审查", content: "风险B", threadId: "t-b" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("同一席位多轮发言只保留最后一条", () => {
|
||||
const dialogues = [
|
||||
bubble({ sender: "方案设计", content: "初稿" }),
|
||||
bubble({ sender: "方案设计", content: "终稿", agentThreadId: "t-final" }),
|
||||
];
|
||||
const result = collectRoundtableDeliverables(dialogues, null);
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0], { agentName: "方案设计", content: "终稿", threadId: "t-final" });
|
||||
});
|
||||
|
||||
test("跳过流式中(streaming)与空内容气泡", () => {
|
||||
const dialogues = [
|
||||
bubble({ sender: "方案设计", content: "正在生成", streaming: true }),
|
||||
bubble({ sender: "风险审查", content: " " }), // 空白
|
||||
bubble({ sender: "数据分析", content: "有效交付" }),
|
||||
];
|
||||
const result = collectRoundtableDeliverables(dialogues, null);
|
||||
assert.deepEqual(result, [{ agentName: "数据分析", content: "有效交付", threadId: null }]);
|
||||
});
|
||||
|
||||
test("流式气泡被同席位后续完成稿覆盖", () => {
|
||||
const dialogues = [
|
||||
bubble({ sender: "方案设计", content: "流式片段", streaming: true }),
|
||||
bubble({ sender: "方案设计", content: "完成稿", agentThreadId: "t-x" }),
|
||||
];
|
||||
const result = collectRoundtableDeliverables(dialogues, null);
|
||||
assert.deepEqual(result, [{ agentName: "方案设计", content: "完成稿", threadId: "t-x" }]);
|
||||
});
|
||||
|
||||
test("无 agentThreadId 时 threadId 为 null", () => {
|
||||
const result = collectRoundtableDeliverables([bubble({ sender: "A", content: "x" })], null);
|
||||
assert.equal(result[0]!.threadId, null);
|
||||
});
|
||||
|
||||
test("空输入返回空数组", () => {
|
||||
assert.deepEqual(collectRoundtableDeliverables([], "总控"), []);
|
||||
});
|
||||
|
||||
test("coordinatorName 为 null 时不剔除任何席位", () => {
|
||||
const dialogues = [
|
||||
bubble({ sender: "总控", content: "也算一条" }),
|
||||
bubble({ sender: "方案设计", content: "方案A" }),
|
||||
];
|
||||
const result = collectRoundtableDeliverables(dialogues, null);
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Step2Dialogue } from "../hooks/useStep2Orchestration";
|
||||
|
||||
/**
|
||||
* 回传父页面(无界)的单个智能体交付。
|
||||
* 与 Vue 宿主 `roundtable-embed/RoundtableDrawer.vue` 的 `RoundtableDeliverable` 对齐。
|
||||
*/
|
||||
export interface RoundtableDeliverable {
|
||||
agentName: string;
|
||||
content: string;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Step 2 全部对话气泡里收集「各智能体最终交付」。
|
||||
*
|
||||
* 规则:按发言者(`sender`)分组,取每个席位**最后一条**「已完成(非流式)且非空」的气泡;
|
||||
* 总控(coordinator)单独回传(`consensusText`),故在此剔除。纯函数,便于单测。
|
||||
*/
|
||||
export function collectRoundtableDeliverables(
|
||||
dialogues: Step2Dialogue[],
|
||||
coordinatorName: string | null,
|
||||
): RoundtableDeliverable[] {
|
||||
const lastBySeat = new Map<string, Step2Dialogue>();
|
||||
for (const d of dialogues) {
|
||||
if (d.streaming) continue;
|
||||
if (!d.content?.trim()) continue;
|
||||
if (coordinatorName && d.sender === coordinatorName) continue;
|
||||
lastBySeat.set(d.sender, d); // 后出现的覆盖前者 → 每席位保留最后一条
|
||||
}
|
||||
return Array.from(lastBySeat.values()).map((d) => ({
|
||||
agentName: d.sender,
|
||||
content: d.content,
|
||||
threadId: d.agentThreadId ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -97,6 +97,8 @@ import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
import { listAgents } from "@/core/agents";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie";
|
||||
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
|
||||
|
||||
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
|
||||
interface RoundtablePrefill {
|
||||
@@ -104,27 +106,85 @@ interface RoundtablePrefill {
|
||||
taskTitle?: string;
|
||||
/** 任务描述(注入 Step 1 首条用户消息)。 */
|
||||
taskGoal?: string;
|
||||
/** 指定业务链条 id(wujie 抽屉直传,优先于 businessCode;据此自动选链)。 */
|
||||
chainId?: string;
|
||||
/** 来源业务码(如 "6BF"):据此查业务映射拿到要自动选用的业务链。 */
|
||||
businessCode?: string;
|
||||
/** 外部任务 id(无界抽屉):聊天记录按此存取、打开时回显最新一条。 */
|
||||
taskId?: string;
|
||||
/** 是否进入即自动开跑 Step 1 意图分析。 */
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 无界(wujie)抽屉场景:父页面通过 `window.$wujie.props` 注入「任务名称/描述/业务链条」。
|
||||
* iframe 降级时 props 可能晚于首帧,故再读 hash 路由 query 作兜底(宿主 `appendEmbedQueryToUrl` 写入)。
|
||||
*/
|
||||
function readWujiePrefill(): RoundtablePrefill | null {
|
||||
if (!isWujieEnv()) return null;
|
||||
const p = readWujieProps();
|
||||
const taskTitle = typeof p.taskTitle === "string" ? p.taskTitle : undefined;
|
||||
const taskGoal = typeof p.taskGoal === "string" ? p.taskGoal : undefined;
|
||||
const chainId = typeof p.chainId === "string" ? p.chainId : undefined;
|
||||
const businessCode = typeof p.businessCode === "string" ? p.businessCode : undefined;
|
||||
const taskId = typeof p.taskId === "string" ? p.taskId : undefined;
|
||||
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) {
|
||||
return readEmbedHashQueryPrefill();
|
||||
}
|
||||
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
|
||||
}
|
||||
|
||||
/** 从 `#/embed/roundtable?…` 的 hash query 读取预填(iframe 降级兜底)。 */
|
||||
function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const hash = window.location.hash || "";
|
||||
if (!hash.includes("/embed/roundtable")) return null;
|
||||
const qIndex = hash.indexOf("?");
|
||||
if (qIndex < 0) return null;
|
||||
const params = new URLSearchParams(hash.slice(qIndex + 1));
|
||||
const taskTitle = params.get("taskTitle") ?? undefined;
|
||||
const taskGoal = params.get("taskGoal") ?? undefined;
|
||||
const chainId = params.get("chainId") ?? undefined;
|
||||
const businessCode = params.get("businessCode") ?? undefined;
|
||||
const taskId = params.get("taskId") ?? undefined;
|
||||
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) return null;
|
||||
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
|
||||
}
|
||||
|
||||
|
||||
export default function RoundtablePlanningPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
|
||||
const inWujieEmbed = isWujieEnv();
|
||||
|
||||
// ─── 会商自动化串联(模块 C):任务页带进来的预填 ─────────────────
|
||||
// 只在首次渲染读一次 navigation state;随后清掉 history state,避免刷新/后退重放。
|
||||
// navigation state(站内跳转)优先;无界抽屉场景回退读 window.$wujie.props。
|
||||
const prefillRef = useRef<RoundtablePrefill | null>(
|
||||
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null,
|
||||
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ??
|
||||
readWujiePrefill(),
|
||||
);
|
||||
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
||||
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
|
||||
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
|
||||
const [autoChainLoading, setAutoChainLoading] = useState(
|
||||
() =>
|
||||
inWujieEmbed &&
|
||||
!!(prefillRef.current?.chainId || prefillRef.current?.businessCode),
|
||||
);
|
||||
const autoStartedRef = useRef(false);
|
||||
const prefillClearedRef = useRef(false);
|
||||
// 共识达成后只向父页面(无界)回传一次交付结果。
|
||||
const consensusEmittedRef = useRef(false);
|
||||
// 带 taskId 时,先按任务回显最新草稿再决定是否 autoStart。用 state 而非 ref:
|
||||
// 解析完成需触发重渲染让 C2 effect 重跑(否则 intentThreadId 先就绪会错过 autoStart)。
|
||||
// 无 taskId 时初始即「已解析」,autoStart 照常。
|
||||
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
|
||||
!prefillRef.current?.taskId,
|
||||
);
|
||||
|
||||
const { models: availableModels, isLoading: modelsLoading } = useModels();
|
||||
|
||||
@@ -448,6 +508,7 @@ export default function RoundtablePlanningPage() {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
triggerToast,
|
||||
taskId: prefillRef.current?.taskId ?? null,
|
||||
intentSending: step1.isIntentSending,
|
||||
step2FinalizedCount,
|
||||
step3SavedTick,
|
||||
@@ -638,6 +699,8 @@ export default function RoundtablePlanningPage() {
|
||||
step2.resetStep2State();
|
||||
step2.setSelectedAgents(normalized);
|
||||
step2.resetInitGate();
|
||||
// 新一轮研讨 → 允许共识达成后再次向父页面回传。
|
||||
consensusEmittedRef.current = false;
|
||||
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
|
||||
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
|
||||
setOrchestrationMode(result.mode);
|
||||
@@ -669,11 +732,34 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart
|
||||
// (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。
|
||||
useEffect(() => {
|
||||
const tid = prefillRef.current?.taskId;
|
||||
if (!tid) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const restored = await drafts.loadLatestByTask(tid);
|
||||
if (cancelled) return;
|
||||
if (restored) autoStartedRef.current = true; // 命中历史 → 不再自动发首条消息
|
||||
} finally {
|
||||
if (!cancelled) setTaskRestoreResolved(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅挂载时执行一次
|
||||
|
||||
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
|
||||
// 触发任务理解智能体自动分析(只发一次)。
|
||||
useEffect(() => {
|
||||
const prefill = prefillRef.current;
|
||||
if (!prefill?.autoStart || autoStartedRef.current) return;
|
||||
// 带 taskId 时,等「按任务回显」决策完成再决定是否 autoStart,避免与历史回显抢跑。
|
||||
if (!taskRestoreResolved) return;
|
||||
if (currentStep !== 1) return;
|
||||
if (!step1.intentThreadId || step1.intentReady) return;
|
||||
const title = prefill.taskTitle?.trim();
|
||||
@@ -684,7 +770,7 @@ export default function RoundtablePlanningPage() {
|
||||
if (!text) return;
|
||||
autoStartedRef.current = true;
|
||||
void step1.sendIntentMessage(text);
|
||||
}, [currentStep, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]);
|
||||
}, [currentStep, taskRestoreResolved, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]);
|
||||
|
||||
// C3:据 prefill.businessCode 查业务映射 → 取 chainId → 拉公共链 + 候选智能体 → 构造自动选链结果。
|
||||
// 解析成功后「进入研讨」会跳过选链弹窗直接据此进 Step 2;解析失败(未配置 / 链不存在 / 席位不足)
|
||||
@@ -695,13 +781,17 @@ export default function RoundtablePlanningPage() {
|
||||
// 末次 setup 正常落地(生产无 StrictMode 只跑一次)。
|
||||
useEffect(() => {
|
||||
const prefill = prefillRef.current;
|
||||
if (!prefill?.businessCode) return;
|
||||
if (!prefill?.chainId && !prefill?.businessCode) return;
|
||||
setAutoChainLoading(true);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
const row = mappings.find((m) => m.businessCode === prefill.businessCode);
|
||||
const chainId = row?.chainId;
|
||||
// chainId 直传(无界抽屉)优先;否则据 businessCode 查业务映射拿 chainId。
|
||||
let chainId: string | null | undefined = prefill.chainId;
|
||||
if (!chainId && prefill.businessCode) {
|
||||
const mappings = await listBusinessMappings();
|
||||
chainId = mappings.find((m) => m.businessCode === prefill.businessCode)?.chainId;
|
||||
}
|
||||
if (!chainId) return;
|
||||
const [chain, agents] = await Promise.all([
|
||||
getChain(chainId),
|
||||
@@ -713,6 +803,8 @@ export default function RoundtablePlanningPage() {
|
||||
if (!cancelled && result) setAutoChainResult(result);
|
||||
} catch {
|
||||
/* 解析失败 → 保持 null,回退弹窗手选 */
|
||||
} finally {
|
||||
if (!cancelled) setAutoChainLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -720,6 +812,56 @@ export default function RoundtablePlanningPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 组装无界回传载荷:总控共识 + 各智能体最终交付(剔除总控席位)。 */
|
||||
const buildWujieConsensusPayload = useCallback(
|
||||
() => ({
|
||||
taskTitle: prefillRef.current?.taskTitle ?? null,
|
||||
chainId: prefillRef.current?.chainId ?? null,
|
||||
chainTitle: chainMeta?.title ?? null,
|
||||
coordinatorName: step2.coordinatorName,
|
||||
consensusText: step2.lastLeaderContent,
|
||||
deliverables: collectRoundtableDeliverables(
|
||||
step2.step2RoundtableDialogues,
|
||||
step2.coordinatorName,
|
||||
),
|
||||
}),
|
||||
[
|
||||
chainMeta,
|
||||
step2.coordinatorName,
|
||||
step2.lastLeaderContent,
|
||||
step2.step2RoundtableDialogues,
|
||||
],
|
||||
);
|
||||
|
||||
const emitConsensusToParent = useCallback(() => {
|
||||
if (!inWujieEmbed) return false;
|
||||
return emitWujieEvent("roundtable:consensus", buildWujieConsensusPayload());
|
||||
}, [buildWujieConsensusPayload, inWujieEmbed]);
|
||||
|
||||
const handleSettingsClick = useCallback(() => {
|
||||
if (inWujieEmbed) {
|
||||
const deliverables = buildWujieConsensusPayload().deliverables;
|
||||
if (deliverables.length === 0) {
|
||||
triggerToast("暂无可回传的智能体交付结果");
|
||||
return;
|
||||
}
|
||||
if (emitConsensusToParent()) {
|
||||
triggerToast(`已回传 ${deliverables.length} 个智能体交付结果`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)");
|
||||
}, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, triggerToast]);
|
||||
|
||||
// C4(无界回传):第二步达成共识后,把总控结论 + 各智能体交付一次性广播给父页面。
|
||||
// 用 ref 防重(共识态会多次触发渲染);非无界环境静默跳过。
|
||||
useEffect(() => {
|
||||
if (!step2.hasConsensus || consensusEmittedRef.current) return;
|
||||
if (!inWujieEmbed) return;
|
||||
consensusEmittedRef.current = true;
|
||||
emitConsensusToParent();
|
||||
}, [step2.hasConsensus, inWujieEmbed, emitConsensusToParent]);
|
||||
|
||||
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
|
||||
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
|
||||
// 在历史记录里可见)。
|
||||
@@ -736,6 +878,8 @@ export default function RoundtablePlanningPage() {
|
||||
step1.stopIntentStream();
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
drafts.setCurrentDraftId(null);
|
||||
// 重置后新一轮共识仍需回传父页面。
|
||||
consensusEmittedRef.current = false;
|
||||
setCurrentStep(1);
|
||||
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
|
||||
// currentStep 不变,useEffect 不会 re-fire。
|
||||
@@ -747,6 +891,14 @@ export default function RoundtablePlanningPage() {
|
||||
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
|
||||
}, [resetToStep1, triggerToast]);
|
||||
|
||||
// 无界抽屉保活复用:父页面重开抽屉时 `bus.$emit('roundtable:reset')` → 子应用回到第一步。
|
||||
// 非无界环境下 onWujieEvent 返回 no-op,安全无副作用。
|
||||
useEffect(() => {
|
||||
return onWujieEvent("roundtable:reset", () => {
|
||||
resetToStep1();
|
||||
});
|
||||
}, [resetToStep1]);
|
||||
|
||||
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
@@ -900,15 +1052,17 @@ export default function RoundtablePlanningPage() {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
id="business-chain-btn"
|
||||
onClick={() => setIsChainListOpen(true)}
|
||||
title="查看 / 配置可复用的业务链条(有序智能体编排)"
|
||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5 text-primary" />
|
||||
业务链条
|
||||
</button>
|
||||
{!inWujieEmbed && (
|
||||
<button
|
||||
id="business-chain-btn"
|
||||
onClick={() => setIsChainListOpen(true)}
|
||||
title="查看 / 配置可复用的业务链条(有序智能体编排)"
|
||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5 text-primary" />
|
||||
业务链条
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="new-task-btn"
|
||||
@@ -967,7 +1121,8 @@ export default function RoundtablePlanningPage() {
|
||||
|
||||
<button
|
||||
id="settings-btn"
|
||||
onClick={() => triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)")}
|
||||
onClick={handleSettingsClick}
|
||||
title={inWujieEmbed ? "回传各智能体交付结果给宿主" : "系统设置"}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -1026,6 +1181,14 @@ export default function RoundtablePlanningPage() {
|
||||
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inWujieEmbed &&
|
||||
autoChainLoading &&
|
||||
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
|
||||
) {
|
||||
triggerToast("正在加载业务链条,请稍候…");
|
||||
return;
|
||||
}
|
||||
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
|
||||
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
|
||||
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
|
||||
|
||||
@@ -148,15 +148,16 @@
|
||||
|
||||
/* ---- 表面:白 / 近白底 → 卡片底 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-white, .hover\:bg-white:hover, .bg-\[\#fcfcfd\], .bg-\[\#fffdfa\]) {
|
||||
:is(.bg-white, .bg-white\/70, .hover\:bg-white:hover, .bg-\[\#fcfcfd\], .bg-\[\#fffdfa\]) {
|
||||
background-color: var(--card) !important;
|
||||
}
|
||||
|
||||
/* ---- 次级表面:slate-50/100(含透明度档)→ muted ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.bg-slate-50, .bg-slate-50\/20, .bg-slate-50\/30, .bg-slate-50\/50,
|
||||
.bg-slate-50\/60, .bg-slate-50\/80, .bg-slate-100, .bg-slate-100\/80
|
||||
.bg-slate-50, .bg-slate-50\/20, .bg-slate-50\/30, .bg-slate-50\/40,
|
||||
.bg-slate-50\/50, .bg-slate-50\/60, .bg-slate-50\/80, .bg-slate-100,
|
||||
.bg-slate-100\/80
|
||||
) {
|
||||
background-color: var(--muted) !important;
|
||||
}
|
||||
@@ -226,7 +227,7 @@
|
||||
/* ---- 中性边框:slate / red / 白 / #e2e8f0(含透明度档)→ border ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.border-white, .border-slate-50, .border-slate-100, .border-slate-150,
|
||||
.border-white, .border-white\/70, .border-slate-50, .border-slate-100, .border-slate-150,
|
||||
.border-slate-200, .border-slate-200\/50, .border-slate-200\/80,
|
||||
.border-slate-300, .border-slate-400, .border-slate-700,
|
||||
.border-\[\#e2e8f0\],
|
||||
|
||||
@@ -337,6 +337,12 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 无界嵌入:Radix scroll-lock 可能误算 margin-right,留下右侧空白条 */
|
||||
html.wujie-embed body {
|
||||
margin-right: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] {
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
font-weight: 600;
|
||||
|
||||
Reference in new issue
Block a user