+ {/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
+ {!showWelcome && (
+
-
-
当前对话模型
- {modelsLoading || models.length === 0 ? (
-
- {modelsLoading ? '加载中…' : '暂无模型'}
+
+ {/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
+
+
+
+
+
当前对话模型
+ {modelsLoading || models.length === 0 ? (
+
+ {modelsLoading ? '加载中…' : '暂无模型'}
+
+ ) : (
+
+ )}
- ) : (
-
- )}
+
+ 输入想法,按 Enter 直接解析填充
+
+
+
void handleChatSubmit(text)}>
+
+
+
+
+
-
void handleChatSubmit(text)}>
-
-
-
-
-
diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx
new file mode 100644
index 00000000..30110ee4
--- /dev/null
+++ b/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx
@@ -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 (
+
+
+ {/* 标题 + 介绍 + 三步引导卡片:在区域里垂直居中 */}
+
+
+
+ 欢迎使用「妙笔 AI」智力打字协作工坊
+
+
+ 这是一个专业的自动化写作系统。你只需在下方对话框中
+
+ 输入你的本意(例如:「帮我写一篇 1500 字的新能源汽车报告」)
+
+ ,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
+
+
+
+
+ {STEPS.map((step) => (
+
+
+
+
+
+ {step.n}. {step.title}
+
+
{step.desc}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/pages/EmbedRoundtablePage.tsx b/frontend-web/src/pages/EmbedRoundtablePage.tsx
new file mode 100644
index 00000000..2d626389
--- /dev/null
+++ b/frontend-web/src/pages/EmbedRoundtablePage.tsx
@@ -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
(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 (
+
+ 登录失败:{authError}
+
+ );
+ }
+
+ if (!authReady) {
+ return (
+
+ 正在进入圆桌…
+
+ );
+ }
+
+ // RoundtablePlanningPage 依赖 SubtasksProvider / ArtifactsProvider / PromptInputProvider
+ // (Step 2 沙箱用 useArtifacts)。ChatRuntime 正是这三者的封装;不套 WorkspaceLayout,
+ // 故无侧边栏,只渲染圆桌主体。
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts
index 244d97fe..ac9ba0ff 100644
--- a/frontend-web/src/roundtable-planning/api/drafts.ts
+++ b/frontend-web/src/roundtable-planning/api/drafts.ts
@@ -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(res: Response, ctx: string): Promise {
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 = {};
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 {
return toRecord(data);
}
+/**
+ * 按外部任务 id 取最新一条草稿(后端忽略 user,不分权)。无则返回 null(404)。
+ * 无界嵌入抽屉打开时用于「该任务有聊天记录则回显最新一条」。
+ */
+export async function getLatestDraftByTask(taskId: string): Promise {
+ const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}/latest`, { method: "GET" });
+ if (res.status === 404) return null;
+ const data = await readJson(res, "get latest draft by task");
+ return toRecord(data);
+}
+
export async function createDraft(payload: DraftCreatePayload): Promise {
const res = await apiFetch(BASE(), {
method: "POST",
diff --git a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts
index a0cf8b8a..9968b16d 100644
--- a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts
@@ -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>;
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>;
handleManualSave: () => void;
handleLoadDraft: (id: string) => void;
+ /** 按外部任务 id 取最新草稿并 hydrate,返回是否命中(用于抑制 autoStart)。 */
+ loadLatestByTask: (taskId: string) => Promise;
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(taskId ?? null);
+ useEffect(() => {
+ taskIdRef.current = taskId ?? null;
+ }, [taskId]);
+
const [drafts, setDrafts] = useState([]);
const [currentDraftId, setCurrentDraftIdState] = useState(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(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 => {
+ 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,
diff --git a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts
new file mode 100644
index 00000000..b8beb631
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts
@@ -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 & { 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);
+});
diff --git a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts
new file mode 100644
index 00000000..5219e7ec
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts
@@ -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();
+ 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,
+ }));
+}
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index 6ed9b8c0..b01d7c15 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -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(
- ((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null,
+ ((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ??
+ readWujiePrefill(),
);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState(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(
+ !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() {
/>
-
+ {!inWujieEmbed && (
+
+ )}
+
+
+
+
+ 圆桌规划抽屉 · wujie 嵌入 Demo
+
+
+
+
+
+
+
+
+
+
+ 打开圆桌规划抽屉
+
+
+
+
+
+
✅ 已收到共识回传(roundtable:consensus)
+
+ 链条:{{ rtConsensus.chainTitle || rtConsensus.chainId || "—" }}|总控:{{ rtConsensus.coordinatorName || "—" }}
+
+
{{ rtConsensus.consensusText }}
+
+
{{ d.agentName }}
+
{{ d.content }}
+
+
+
+
+
+
diff --git a/parallel-agents-vue/src/roundtable-embed/RoundtableDrawer.vue b/parallel-agents-vue/src/roundtable-embed/RoundtableDrawer.vue
new file mode 100644
index 00000000..df72c4c6
--- /dev/null
+++ b/parallel-agents-vue/src/roundtable-embed/RoundtableDrawer.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
diff --git a/parallel-agents-vue/src/roundtable-embed/index.ts b/parallel-agents-vue/src/roundtable-embed/index.ts
new file mode 100644
index 00000000..4f279f58
--- /dev/null
+++ b/parallel-agents-vue/src/roundtable-embed/index.ts
@@ -0,0 +1,5 @@
+export { default as RoundtableDrawer } from "./RoundtableDrawer.vue";
+export type {
+ RoundtableConsensusPayload,
+ RoundtableDeliverable,
+} from "./RoundtableDrawer.vue";
diff --git a/parallel-agents-vue/src/roundtable-embed/resolve-wujie-degrade.ts b/parallel-agents-vue/src/roundtable-embed/resolve-wujie-degrade.ts
new file mode 100644
index 00000000..d2e43751
--- /dev/null
+++ b/parallel-agents-vue/src/roundtable-embed/resolve-wujie-degrade.ts
@@ -0,0 +1,13 @@
+import { isCrossOriginBaseUrl } from "./wujie-origin";
+
+export type WujieDegradeMode = boolean | "auto";
+
+/** 解析无界是否走 iframe 降级(跨域且 Nginx 未配 CORS 时必须降级)。 */
+export function resolveWujieDegrade(
+ baseUrl: string,
+ degrade: WujieDegradeMode,
+): boolean {
+ if (degrade === true) return true;
+ if (degrade === false) return false;
+ return isCrossOriginBaseUrl(baseUrl);
+}
diff --git a/parallel-agents-vue/src/roundtable-embed/wujie-fetch.ts b/parallel-agents-vue/src/roundtable-embed/wujie-fetch.ts
new file mode 100644
index 00000000..c4027d9e
--- /dev/null
+++ b/parallel-agents-vue/src/roundtable-embed/wujie-fetch.ts
@@ -0,0 +1,11 @@
+/**
+ * 无界跨域加载子应用 HTML/JS/CSS 时的 fetch 封装。
+ * 宿主与子应用不同源时必须携带 credentials,否则子应用资源或 Cookie 可能拉不下来。
+ * @see https://wujie-micro.github.io/doc/question/
+ */
+export function wujieCredentialsFetch(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+): Promise
{
+ return window.fetch(input, { ...init, credentials: "include" });
+}
diff --git a/parallel-agents-vue/src/roundtable-embed/wujie-origin.ts b/parallel-agents-vue/src/roundtable-embed/wujie-origin.ts
new file mode 100644
index 00000000..e90ae23b
--- /dev/null
+++ b/parallel-agents-vue/src/roundtable-embed/wujie-origin.ts
@@ -0,0 +1,48 @@
+/** 解析圆桌嵌入完整 URL(支持同源相对路径,如 `/deerflow`)。 */
+export function resolveRoundtableEmbedUrl(baseUrl: string): string {
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
+ if (!trimmed) return "/#/embed/roundtable";
+ if (trimmed.startsWith("/")) {
+ return `${window.location.origin}${trimmed}/#/embed/roundtable`;
+ }
+ return `${trimmed}/#/embed/roundtable`;
+}
+
+/** baseUrl 是否与当前宿主页面不同源(跨域嵌入)。相对路径视为同源。 */
+export function isCrossOriginBaseUrl(baseUrl: string): boolean {
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
+ if (!trimmed || trimmed.startsWith("/")) return false;
+ try {
+ const target = new URL(trimmed, window.location.href);
+ return target.origin !== window.location.origin;
+ } catch {
+ return true;
+ }
+}
+
+export interface RoundtableEmbedQuery {
+ taskTitle?: string;
+ taskGoal?: string;
+ chainId?: string;
+ taskId?: string;
+ username?: string;
+ password?: string;
+ theme?: string;
+}
+
+/** iframe 降级模式下把关键参数挂到 hash 路由 query,作 props 注入的兜底。 */
+export function appendEmbedQueryToUrl(
+ embedUrl: string,
+ fields: RoundtableEmbedQuery,
+): string {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(fields)) {
+ if (value != null && String(value).length > 0) {
+ params.set(key, String(value));
+ }
+ }
+ const qs = params.toString();
+ if (!qs) return embedUrl;
+ const sep = embedUrl.includes("?") ? "&" : "?";
+ return `${embedUrl}${sep}${qs}`;
+}