wujie应用联调
This commit is contained in:
1 parent
0784f30e23
commit
f3ca996116
35 files changed
+1590
-334
No files matched your search
@@ -14,8 +14,8 @@
|
||||
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
|
||||
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
|
||||
#
|
||||
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
|
||||
# 若浏览器直连后端报 CORS,请在 Gateway 配置 GATEWAY_CORS_ORIGINS,包含前端源站;无界嵌入时还要加宿主源站,例如:
|
||||
# GATEWAY_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:5183,http://localhost:5183
|
||||
|
||||
# --- 开发环境(见 .env.development)---
|
||||
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -163,17 +163,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>
|
||||
@@ -368,7 +370,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,115 @@
|
||||
"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 } 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(() => {
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,12 +106,52 @@ 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";
|
||||
@@ -118,13 +160,23 @@ export default function RoundtablePlanningPage() {
|
||||
|
||||
// ─── 会商自动化串联(模块 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);
|
||||
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 +500,7 @@ export default function RoundtablePlanningPage() {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
triggerToast,
|
||||
taskId: prefillRef.current?.taskId ?? null,
|
||||
intentSending: step1.isIntentSending,
|
||||
step2FinalizedCount,
|
||||
step3SavedTick,
|
||||
@@ -638,6 +691,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 +724,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 +762,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 +773,16 @@ export default function RoundtablePlanningPage() {
|
||||
// 末次 setup 正常落地(生产无 StrictMode 只跑一次)。
|
||||
useEffect(() => {
|
||||
const prefill = prefillRef.current;
|
||||
if (!prefill?.businessCode) return;
|
||||
if (!prefill?.chainId && !prefill?.businessCode) return;
|
||||
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),
|
||||
@@ -720,6 +801,31 @@ export default function RoundtablePlanningPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// C4(无界回传):第二步达成共识后,把总控结论 + 各智能体交付一次性广播给父页面。
|
||||
// 用 ref 防重(共识态会多次触发渲染);非无界环境静默跳过。
|
||||
useEffect(() => {
|
||||
if (!step2.hasConsensus || consensusEmittedRef.current) return;
|
||||
if (!isWujieEnv()) return;
|
||||
consensusEmittedRef.current = true;
|
||||
emitWujieEvent("roundtable:consensus", {
|
||||
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,
|
||||
),
|
||||
});
|
||||
}, [
|
||||
step2.hasConsensus,
|
||||
step2.coordinatorName,
|
||||
step2.lastLeaderContent,
|
||||
step2.step2RoundtableDialogues,
|
||||
chainMeta,
|
||||
]);
|
||||
|
||||
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
|
||||
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
|
||||
// 在历史记录里可见)。
|
||||
@@ -736,6 +842,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 +855,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 () => {
|
||||
|
||||
@@ -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\],
|
||||
|
||||
@@ -35,6 +35,9 @@ export default defineConfig({
|
||||
},
|
||||
"base":"./",
|
||||
server: {
|
||||
port: 5173,
|
||||
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
|
||||
port: 5174,
|
||||
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
|
||||
cors: true,
|
||||
},
|
||||
});
|
||||
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
|
||||
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
|
||||
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
|
||||
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5183,http://localhost:5174,http://127.0.0.1:5174,http://localhost:4200
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:5183,http://127.0.0.1:5183,http://localhost:4200
|
||||
GATEWAY_CORS_ALLOW_ALL=0
|
||||
GATEWAY_ENABLE_DOCS=true
|
||||
GATEWAY_WORKERS=2
|
||||
|
||||
@@ -39,6 +39,8 @@ router = APIRouter(prefix="/api/roundtable-drafts", tags=["roundtable-drafts"])
|
||||
|
||||
class DraftMetaResponse(BaseModel):
|
||||
id: str
|
||||
# 外部任务 id(无界嵌入抽屉传入);非空时草稿按 task_id 共享、不按 user 分权。
|
||||
task_id: str | None = None
|
||||
title: str = ""
|
||||
furthest_step: int = 1
|
||||
created_at: datetime | str | None = None
|
||||
@@ -62,6 +64,8 @@ class DraftCreateRequest(BaseModel):
|
||||
# Optional client-provided id so the browser can keep one stable id across
|
||||
# the create + subsequent updates without an extra round-trip.
|
||||
id: str | None = Field(default=None, max_length=64)
|
||||
# 外部任务 id(无界嵌入抽屉);设置后草稿按 task_id 共享、不按 user 分权。
|
||||
task_id: str | None = Field(default=None, max_length=128)
|
||||
title: str = Field(default="", max_length=512)
|
||||
furthest_step: int = Field(default=1, ge=1, le=3)
|
||||
step1: dict[str, Any] | None = None
|
||||
@@ -74,6 +78,7 @@ class DraftUpdateRequest(BaseModel):
|
||||
# so it never clobbers a manual rename.
|
||||
title: str | None = Field(default=None, max_length=512)
|
||||
furthest_step: int | None = Field(default=None, ge=1, le=3)
|
||||
task_id: str | None = Field(default=None, max_length=128)
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
step3: dict[str, Any] | None = None
|
||||
@@ -165,6 +170,7 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
|
||||
user_id,
|
||||
{
|
||||
"id": (body.id or uuid4().hex),
|
||||
"task_id": (body.task_id or None),
|
||||
"title": body.title.strip(),
|
||||
"furthest_step": body.furthest_step,
|
||||
"step1": body.step1,
|
||||
@@ -175,6 +181,19 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}/latest", response_model=DraftResponse)
|
||||
async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftResponse:
|
||||
"""按外部任务 id 取最新一条草稿(**不按 user 分权**);无则 404。
|
||||
|
||||
无界嵌入抽屉打开时调用:若该任务已有聊天记录,回显最新一条。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
row = await store.get_latest_draft_by_task(task_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="No draft for this task")
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftResponse)
|
||||
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""Add task_id column to roundtable_drafts.
|
||||
|
||||
External task id (passed by the wujie-embedded roundtable drawer). When set, the
|
||||
draft is keyed/shared by ``task_id`` and is no longer partitioned by ``user_id``
|
||||
(read/write/delete ownership checks are relaxed for task-bound drafts — see
|
||||
``sql.py``); when NULL the original per-user isolation is preserved.
|
||||
``create_all`` adds the column for brand-new DBs; this revision patches DBs that
|
||||
already have the table.
|
||||
|
||||
Revision ID: 20260611_01
|
||||
Revises: 20260609_02
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260611_01"
|
||||
down_revision = "20260609_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_drafts") and not _has_column("roundtable_drafts", "task_id"):
|
||||
op.add_column("roundtable_drafts", sa.Column("task_id", sa.String(128), nullable=True))
|
||||
op.create_index("ix_roundtable_drafts_task_id", "roundtable_drafts", ["task_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_drafts", "task_id"):
|
||||
op.drop_index("ix_roundtable_drafts_task_id", table_name="roundtable_drafts")
|
||||
op.drop_column("roundtable_drafts", "task_id")
|
||||
+3
@@ -26,6 +26,9 @@ class RoundtableDraftRow(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
# 外部任务 id(无界嵌入抽屉传入)。非空时该草稿按 task_id 共享、不再按 user_id 分权
|
||||
# (读/写/删放开归属校验,见 sql.py);为空时保持原「按登录用户隔离」语义。
|
||||
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
# Auto-derived task summary (objective → first user message → timestamp).
|
||||
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
# Furthest step reached when this snapshot was taken (1 | 2 | 3).
|
||||
|
||||
+24
-3
@@ -53,6 +53,7 @@ class RoundtableDraftRepository:
|
||||
"""Lightweight projection for the history dropdown (no big blobs)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"title": row.title,
|
||||
"furthest_step": row.furthest_step,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
@@ -81,15 +82,30 @@ class RoundtableDraftRepository:
|
||||
async def get_draft(self, draft_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权);非 task 草稿仍按 user 隔离。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
return None
|
||||
return self._draft_to_dict(row)
|
||||
|
||||
async def get_latest_draft_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""按外部任务 id 取最新一条草稿(**忽略 user_id**,不分权)。无则 None。"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return self._draft_to_dict(row) if row is not None else None
|
||||
|
||||
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableDraftRow(
|
||||
id=data["id"],
|
||||
user_id=user_id,
|
||||
task_id=(data.get("task_id") or None),
|
||||
title=(data.get("title") or "").strip(),
|
||||
furthest_step=int(data.get("furthest_step") or 1),
|
||||
step1=_dumps(data.get("step1")),
|
||||
@@ -114,11 +130,15 @@ class RoundtableDraftRepository:
|
||||
step1=_UNSET,
|
||||
step2=_UNSET,
|
||||
step3=_UNSET,
|
||||
task_id=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权)。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
return None
|
||||
if task_id is not _UNSET and task_id is not None:
|
||||
row.task_id = str(task_id)
|
||||
if title is not _UNSET and title is not None:
|
||||
row.title = str(title).strip()
|
||||
if furthest_step is not _UNSET and furthest_step is not None:
|
||||
@@ -137,7 +157,8 @@ class RoundtableDraftRepository:
|
||||
async def delete_draft(self, draft_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftRow, draft_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
# task 绑定的草稿共享:放开 user 归属校验(不分权)。
|
||||
if row is None or (row.user_id != user_id and not row.task_id):
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
|
||||
@@ -63,12 +63,33 @@ CALL _add_column_if_missing('skills', 'name_zh', 'VARCHAR(128) NULL DEFAULT NULL
|
||||
-- ORM: SkillRow.detail = Text, nullable=True。
|
||||
CALL _add_column_if_missing('skills', 'detail', 'TEXT NULL DEFAULT NULL');
|
||||
|
||||
-- ---- roundtable_drafts -------------------------------------------------------
|
||||
-- task_id: 外部任务 id(无界嵌入抽屉传入),对应 migration 20260611_01。非空时草稿
|
||||
-- 按 task_id 共享、不再按 user_id 分权。ORM: RoundtableDraftRow.task_id =
|
||||
-- String(128), nullable=True, index=True。
|
||||
CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
|
||||
-- ---- roundtable_drafts.task_id 索引(幂等) -----------------------------------
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'roundtable_drafts'
|
||||
AND index_name = 'ix_roundtable_drafts_task_id'
|
||||
);
|
||||
SET @idx_ddl = IF(@idx_exists = 0,
|
||||
'CREATE INDEX `ix_roundtable_drafts_task_id` ON `roundtable_drafts` (`task_id`)',
|
||||
'SELECT ''SKIPPED ix_roundtable_drafts_task_id (already exists)'' AS result');
|
||||
PREPARE stmt FROM @idx_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND (table_name = 'skills' AND column_name IN ('name_zh', 'detail'))
|
||||
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id'))
|
||||
ORDER BY table_name, column_name;
|
||||
@@ -0,0 +1,250 @@
|
||||
# 圆桌规划抽屉 · wujie 嵌入使用说明
|
||||
|
||||
把 DeerFlow 的「圆桌规划」页用无界(wujie)嵌进本 Vue3 工程的一个右侧抽屉里:宿主传入
|
||||
任务信息与业务链条,抽屉打开即自动开跑,第二步达成共识后把各智能体交付结果回传给宿主。
|
||||
|
||||
- 抽屉组件:`src/roundtable-embed/RoundtableDrawer.vue`
|
||||
- 演示入口:`src/App.vue` 顶部页签「圆桌规划抽屉(wujie)」
|
||||
- 子应用(DeerFlow)侧入口:`/#/embed/roundtable`(只渲染圆桌主体、无侧边栏、用户名登录)
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速开始
|
||||
|
||||
### 1.1 安装 wujie 依赖(内网离线)
|
||||
见 [`offline-deps/wujie/README.md`](../offline-deps/wujie/README.md)。一键:
|
||||
```powershell
|
||||
./offline-deps/wujie/install-offline.ps1 # Windows
|
||||
```
|
||||
```bash
|
||||
bash ./offline-deps/wujie/install-offline.sh # Linux/macOS
|
||||
```
|
||||
|
||||
### 1.2 启动联调
|
||||
1. 启 DeerFlow 前端(被嵌的子应用):`cd frontend-web && pnpm dev`(默认端口 5174)。
|
||||
2. 启本工程:`pnpm dev`,打开后切到「圆桌规划抽屉(wujie)」页签。
|
||||
3. 填写:**DeerFlow 前端站点源**(如 `http://localhost:5174`)、用户名、密码/口令、任务名称/描述、
|
||||
业务链条 chainId、任务 id、主题(默认深蓝)→ 点「打开圆桌规划抽屉」。
|
||||
|
||||
---
|
||||
|
||||
## 2. 组件用法
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { RoundtableDrawer } from "@/roundtable-embed";
|
||||
import type { RoundtableConsensusPayload } from "@/roundtable-embed";
|
||||
|
||||
const open = ref(false);
|
||||
function onConsensus(p: RoundtableConsensusPayload) {
|
||||
// 第二步达成共识 → 拿到各智能体交付
|
||||
console.log(p.consensusText, p.deliverables);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RoundtableDrawer
|
||||
v-model:open="open"
|
||||
base-url="http://localhost:5174"
|
||||
username="zhangsan"
|
||||
password="123ewq"
|
||||
task-title="XX 产品出海方案规划"
|
||||
task-goal="背景/目标/约束……"
|
||||
chain-id="chain_xxx"
|
||||
task-id="task-001"
|
||||
theme="dark"
|
||||
@consensus="onConsensus"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | 类型 | 必填 | 说明 |
|
||||
|------|------|:--:|------|
|
||||
| `open` (`v-model:open`) | `boolean` | ✓ | 抽屉开关 |
|
||||
| `baseUrl` | `string` | ✓ | **DeerFlow 前端站点源**(如 `http://localhost:5174`),不是后端地址 |
|
||||
| `username` | `string` | | 登录用户名(决定登录哪个账号) |
|
||||
| `password` | `string` | | 用户名+密码登录的**共享口令**;后端开启 `username_login.require_password` 时必填 |
|
||||
| `taskTitle` | `string` | | 任务名称(注入子应用第一步首条消息) |
|
||||
| `taskGoal` | `string` | | 任务描述(注入子应用第一步首条消息) |
|
||||
| `chainId` | `string` | | 指定业务链条 id,子应用据此自动选链进第二步 |
|
||||
| `taskId` | `string` | | 任务 id,聊天记录按此存取;该任务有记录则打开时回显最新一条 |
|
||||
| `theme` | `string` | | 主题色,默认 `dark`(深蓝);传 `light` 走浅色 |
|
||||
| `degrade` | `boolean \| 'auto'` | | 无界 iframe 降级,默认 `auto`:跨域时自动降级(**Nginx 不能配 CORS 时靠这个**) |
|
||||
|
||||
### 事件
|
||||
|
||||
| 事件 | 载荷 | 说明 |
|
||||
|------|------|------|
|
||||
| `update:open` | `boolean` | 抽屉关闭时触发 |
|
||||
| `consensus` | `RoundtableConsensusPayload` | 第二步达成共识时触发,携带各智能体交付 |
|
||||
|
||||
`RoundtableConsensusPayload`:
|
||||
```ts
|
||||
interface RoundtableConsensusPayload {
|
||||
taskTitle: string | null;
|
||||
chainId: string | null;
|
||||
chainTitle: string | null;
|
||||
coordinatorName: string | null; // 总控名
|
||||
consensusText: string; // 总控最终共识正文
|
||||
deliverables: Array<{ // 各智能体交付(剔除总控)
|
||||
agentName: string;
|
||||
content: string;
|
||||
threadId: string | null;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 父 ↔ 子 通信契约
|
||||
|
||||
底层是无界事件总线 `bus`(`wujie`),抽屉组件已封装好,宿主一般无需直接接触。
|
||||
|
||||
- **父 → 子(props 注入)**:`window.$wujie.props = { taskTitle, taskGoal, chainId, taskId, username, theme }`。
|
||||
子应用首帧从 `readWujieProps()` 读取(DeerFlow `core/embed/wujie.ts`)。
|
||||
- **子 → 父(共识回传)**:子应用 `bus.$emit('roundtable:consensus', payload)`,抽屉 `bus.$on` 监听后
|
||||
转成 `@consensus` 事件给宿主。
|
||||
- **父 → 子(重置,保活复用时用)**:`bus.$emit('roundtable:reset')` → 子应用回到第一步。
|
||||
本抽屉默认 `alive=false`(关闭即卸载、下次打开是干净新实例),通常用不到。
|
||||
|
||||
---
|
||||
|
||||
## 4. 行为说明
|
||||
|
||||
### 4.1 自动化流程
|
||||
1. 抽屉打开 → 子应用 `/#/embed/roundtable` 用 **`username` + `password`** 登录、应用主题。
|
||||
2. 带 `taskId` 时先按任务取最新草稿:
|
||||
- **有历史** → 回显最新一条聊天记录(跳到对应步骤),**不再**自动发首条消息。
|
||||
- **无历史** → 自动把「任务名称/描述」作为第一步首条消息发出,触发任务理解分析。
|
||||
3. 用户点「进入多智能体研讨」→ 若带了 `chainId`,跳过选链弹窗、按该业务链条进第二步。
|
||||
4. 第二步总控自然收敛达成共识 → 回传 `roundtable:consensus`。
|
||||
|
||||
### 4.2 聊天记录持久化(按任务 id,不分权)
|
||||
- 传了 `taskId` 后,子应用产生的草稿(第一步意图 + 第二步圆桌)都**绑定该 task_id**。
|
||||
- 这类草稿**按 task_id 共享、不按登录用户分权**:同一 `taskId` 不论谁打开,都能读到/续写同一份;
|
||||
打开时默认回显该任务**最新一条**记录。
|
||||
- 未传 `taskId` 的草稿仍按登录用户隔离(原有行为不变)。
|
||||
- 后端:`roundtable_drafts` 表新增 `task_id` 列;接口 `GET /api/roundtable-drafts/by-task/{task_id}/latest`。
|
||||
老库需跑 `offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql` 补列(或新库
|
||||
`create_all` 自动加列),并重启 Gateway。
|
||||
|
||||
### 4.3 主题色
|
||||
默认 **深蓝(dark)**;其主色为蓝(子应用 dark 主题)。宿主传 `theme="light"` 可切浅色。
|
||||
> 子应用侧的主题映射在 `EmbedRoundtablePage`:`dark`/`dark-blue`/空 → 深蓝,`light`/`红白` → 浅色。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调注意
|
||||
|
||||
1. **`baseUrl` 是前端站点源**(如 `http://localhost:5174`),不要填后端 8001。
|
||||
2. **登录(用户名+密码)**:内网走 `POST /api/v1/auth/login/username`——`username` 决定登录哪个账号
|
||||
(不存在则自动注册),`password` 是后端配置的**单一共享口令**(仅 `auth_login.username_login.require_password`
|
||||
开启时校验,默认口令 `123ewq`),它只是门禁、不区分账号。另一种 token 登录(`login/token`)本嵌入**不使用**。
|
||||
3. **chainId / taskId** 要用后端真实存在的业务链 id / 业务方自有的任务 id。
|
||||
4. **高度自适应**:抽屉已把无界容器逐层撑满(`WujieVue width/height=100%` + `<wujie-app>`/`iframe`
|
||||
铺满),内容超出时由圆桌页内部各列滚动。
|
||||
|
||||
### 5.1 跨域(wujie 三层 origin)
|
||||
|
||||
宿主(本工程,默认 **5183**)嵌 DeerFlow 前端(**5174**),子应用再调 Gateway(**8001**),
|
||||
三层 origin 不同,跨域要分别处理:
|
||||
|
||||
| 现象 | 失败 URL 特征 | 处理 |
|
||||
|------|---------------|------|
|
||||
| 抽屉空白 / 子应用 JS 拉不下来 | `5174` 的 html、`.js` | 子应用 Vite 开 `cors: true`(已配);宿主 `WujieVue` 用 `:fetch="wujieCredentialsFetch"`(已配,见 `wujie-fetch.ts`) |
|
||||
| 一直「正在进入圆桌…」/ 登录失败 | `8001` 的 `/api/...` | Gateway `GATEWAY_CORS_ORIGINS` 加入**宿主页 origin**(含 `localhost` 与 `127.0.0.1` 两种写法),改后重启 Gateway |
|
||||
|
||||
**本地 Gateway 示例**(`offline-backend-20260512/.env`):
|
||||
|
||||
```bash
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,\
|
||||
http://localhost:5183,http://127.0.0.1:5183
|
||||
```
|
||||
|
||||
**排查**:DevTools → Network,看第一个红字请求的 URL 与 Request Headers 里的 `Origin`,
|
||||
把该 origin 原样加入 `GATEWAY_CORS_ORIGINS`。
|
||||
|
||||
**常见坑**:
|
||||
|
||||
- `localhost` 与 `127.0.0.1` 算不同 origin,白名单要各写一条。
|
||||
- `baseUrl` 端口须与子应用 Vite 实际端口一致(默认 **5174**;仅 `pnpm dev` 时看终端 `Local:` 行)。
|
||||
- 浏览器地址栏用哪种 hostname 打开宿主,Gateway 就要允许哪种(例如用 `127.0.0.1:5183` 打开就要加 `http://127.0.0.1:5183`)。
|
||||
|
||||
**生产推荐**:Nginx 同源反代,把宿主、DeerFlow 前端、Gateway API 挂到同一域名下
|
||||
(如 `/`、`/deerflow/`、`/deerflow/api/`),`baseUrl` 改为反代后的子应用路径,可免大部分 CORS。
|
||||
子应用静态站点勿设 `X-Frame-Options: DENY`;若用 credentials,勿对子应用资源回 `Access-Control-Allow-Origin: *`。
|
||||
|
||||
### 5.2 Nginx 不能改 CORS 时(推荐)
|
||||
|
||||
运维无法给 DeerFlow 静态站加 `Access-Control-Allow-Origin` 时,**不要**依赖无界默认的
|
||||
「父页面 fetch 子应用 JS」模式——一定会跨域失败。改用下面两种方案之一:
|
||||
|
||||
#### 方案 A:无界 iframe 降级(默认已开启,零 Nginx 改动)
|
||||
|
||||
`RoundtableDrawer` 默认 `degrade="auto"`:**检测到 `baseUrl` 与宿主不同源时自动走 iframe 降级**。
|
||||
|
||||
| 对比 | 默认模式(fetch 注入) | iframe 降级(`degrade: true`) |
|
||||
|------|------------------------|--------------------------------|
|
||||
| 子应用 HTML/JS 谁加载 | 宿主跨域 fetch | 浏览器 iframe 直接导航 |
|
||||
| 是否需要子站 Nginx CORS | **需要** | **不需要** |
|
||||
| props / bus 通信 | 支持 | 支持 |
|
||||
| 子应用调 API 的 Origin | 往往是**宿主**域名 | **子应用**域名 |
|
||||
|
||||
因此生产跨域嵌入时,Gateway 只需允许 **DeerFlow 前端域名**(子应用 iframe 的 origin),
|
||||
**不必**再为宿主域名加 CORS——前提是 DeerFlow 打包时 API 与子应用同源,例如:
|
||||
|
||||
```bash
|
||||
# frontend-web/.env.production / .env.sit
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||
```
|
||||
|
||||
显式强制降级(跨域场景一般不必写,`auto` 即可):
|
||||
|
||||
```vue
|
||||
<RoundtableDrawer degrade :base-url="https://ch1.b.sit.4.cn/deerflow" ... />
|
||||
```
|
||||
|
||||
**注意**:子站若配置了 `X-Frame-Options: DENY` 或 CSP `frame-ancestors 'none'`,
|
||||
iframe 会被浏览器拦截——这与 CORS 无关,需运维放开「允许被 iframe 嵌入」(或去掉该响应头)。
|
||||
|
||||
#### 方案 B:同源静态目录(彻底无跨域)
|
||||
|
||||
若能在**同一域名**下多挂一个静态目录(只加 `location` 指到 dist,**不必**配 CORS 头),
|
||||
把 DeerFlow 前端 `dist` 部署到宿主站点下,例如:
|
||||
|
||||
```text
|
||||
https://portal.example.com/ → 宿主 Vue
|
||||
https://portal.example.com/deerflow/ → DeerFlow dist(复制或 CI 同步)
|
||||
```
|
||||
|
||||
宿主 `baseUrl` 填**相对路径**:
|
||||
|
||||
```vue
|
||||
<RoundtableDrawer base-url="/deerflow" ... />
|
||||
```
|
||||
|
||||
同源时 `degrade` 自动为 `false`,走无界常规模式,无任何跨域问题。
|
||||
|
||||
---
|
||||
|
||||
## 6. 目录
|
||||
|
||||
```
|
||||
parallel-agents-vue/
|
||||
├── src/roundtable-embed/
|
||||
│ ├── RoundtableDrawer.vue # 抽屉组件(wujie 嵌入 + props/bus 通信)
|
||||
│ ├── wujie-fetch.ts # 跨域 fetch(credentials: include,非降级时用)
|
||||
│ ├── wujie-origin.ts # 同源/跨域判断、相对 baseUrl、降级 query 兜底
|
||||
│ ├── resolve-wujie-degrade.ts
|
||||
│ └── index.ts # 导出组件 + 类型
|
||||
├── src/App.vue # Demo 页签入口
|
||||
├── offline-deps/wujie/ # wujie 离线依赖包(内网安装)
|
||||
│ ├── *.tgz # wujie-vue3 / wujie / @babel/runtime
|
||||
│ ├── install-offline.ps1/.sh
|
||||
│ └── README.md
|
||||
└── docs/圆桌规划抽屉-wujie-嵌入使用说明.md # 本文
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# wujie 离线依赖包(内网无网安装)
|
||||
|
||||
圆桌规划抽屉(`src/roundtable-embed/`)用无界(wujie)把 DeerFlow 圆桌页嵌进抽屉,需要以下
|
||||
依赖。内网环境无法联网拉包,故把它们及全部传递依赖预先打成 `.tgz` 放在本目录。
|
||||
|
||||
## 依赖清单(共 3 个,已全部含在本目录)
|
||||
|
||||
| 包 | 版本 | 说明 |
|
||||
|----|------|------|
|
||||
| `wujie-vue3` | 2.0.2 | 无界 Vue3 封装组件(`<WujieVue>` + `bus`) |
|
||||
| `wujie` | 2.0.2 | 无界内核(`wujie-vue3` 的依赖) |
|
||||
| `@babel/runtime` | 7.29.7 | `wujie` 的唯一依赖(自身无下游依赖) |
|
||||
|
||||
> `vue@^3` 是 `wujie-vue3` 的 peer 依赖,项目本就有,无需打包。
|
||||
> 经核对,上述 3 个包之外**没有任何其他传递依赖**。
|
||||
|
||||
## 安装(任选一种)
|
||||
|
||||
> 全程不联网。命令里**依赖在前、消费方在后**,让本地 tarball 互相满足 semver 范围。
|
||||
|
||||
### 方式 A:脚本一键装(推荐)
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
./install-offline.ps1
|
||||
```
|
||||
```bash
|
||||
# Linux / macOS / Git-Bash
|
||||
bash ./install-offline.sh
|
||||
```
|
||||
脚本会自动 `cd` 到项目根、优先用 pnpm(无则 npm)离线安装。
|
||||
|
||||
### 方式 B:手动命令
|
||||
在**项目根目录**(`parallel-agents-vue/`)执行:
|
||||
```bash
|
||||
# pnpm
|
||||
pnpm add --offline \
|
||||
./offline-deps/wujie/babel-runtime-7.29.7.tgz \
|
||||
./offline-deps/wujie/wujie-2.0.2.tgz \
|
||||
./offline-deps/wujie/wujie-vue3-2.0.2.tgz
|
||||
|
||||
# 或 npm
|
||||
npm install --offline \
|
||||
./offline-deps/wujie/babel-runtime-7.29.7.tgz \
|
||||
./offline-deps/wujie/wujie-2.0.2.tgz \
|
||||
./offline-deps/wujie/wujie-vue3-2.0.2.tgz
|
||||
```
|
||||
|
||||
### 方式 C:纯解压兜底(连包管理器都不想用时)
|
||||
npm pack 的 tarball 内层是 `package/` 目录,直接解压改名进 `node_modules`:
|
||||
```bash
|
||||
cd parallel-agents-vue
|
||||
mkdir -p node_modules/@babel
|
||||
tar -xzf offline-deps/wujie/wujie-2.0.2.tgz && rm -rf node_modules/wujie && mv package node_modules/wujie
|
||||
tar -xzf offline-deps/wujie/wujie-vue3-2.0.2.tgz && rm -rf node_modules/wujie-vue3 && mv package node_modules/wujie-vue3
|
||||
tar -xzf offline-deps/wujie/babel-runtime-7.29.7.tgz && rm -rf node_modules/@babel/runtime && mv package node_modules/@babel/runtime
|
||||
```
|
||||
(Win10+/Linux/macOS 均自带 `tar`。这三个包是纯 JS、无原生编译,复制即可用。)
|
||||
|
||||
## 验证
|
||||
```bash
|
||||
node -e "console.log(require('wujie-vue3/package.json').version, require('wujie/package.json').version, require('@babel/runtime/package.json').version)"
|
||||
# 期望输出: 2.0.2 2.0.2 7.29.7
|
||||
```
|
||||
之后 `pnpm dev` / `npm run dev` 打开「圆桌规划抽屉(wujie)」页签即可。
|
||||
|
||||
## 重新生成这些 tarball(有网机器上)
|
||||
```bash
|
||||
cd offline-deps/wujie
|
||||
npm pack wujie@2.0.2 wujie-vue3@2.0.2 @babel/runtime@7.29.7
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
# 离线安装 wujie 相关依赖(内网无网环境)。
|
||||
# 用法:在本文件所在目录右键「使用 PowerShell 运行」,或:
|
||||
# pwsh ./install-offline.ps1
|
||||
#
|
||||
# 依赖集(已全部打进同目录 .tgz,无其他传递依赖;vue 为已存在的 peer):
|
||||
# wujie-vue3@2.0.2 -> wujie@2.0.2 -> @babel/runtime@7.29.7
|
||||
$ErrorActionPreference = "Stop"
|
||||
$dir = $PSScriptRoot
|
||||
$proj = (Resolve-Path (Join-Path $dir "..\..")).Path
|
||||
|
||||
# 依赖在前、消费方在后,便于本地 tarball 互相满足 semver 范围(不触网)。
|
||||
$tgz = @(
|
||||
(Join-Path $dir "babel-runtime-7.29.7.tgz"),
|
||||
(Join-Path $dir "wujie-2.0.2.tgz"),
|
||||
(Join-Path $dir "wujie-vue3-2.0.2.tgz")
|
||||
)
|
||||
|
||||
Push-Location $proj
|
||||
try {
|
||||
$hasPnpm = $null -ne (Get-Command pnpm -ErrorAction SilentlyContinue)
|
||||
if ($hasPnpm) {
|
||||
Write-Host "[offline] 使用 pnpm 离线安装 wujie 依赖 ..." -ForegroundColor Cyan
|
||||
& pnpm add --offline @tgz
|
||||
} else {
|
||||
Write-Host "[offline] 未找到 pnpm,改用 npm 离线安装 ..." -ForegroundColor Cyan
|
||||
& npm install --offline @tgz
|
||||
}
|
||||
Write-Host "[offline] 完成。可运行 'pnpm dev' / 'npm run dev' 验证。" -ForegroundColor Green
|
||||
}
|
||||
finally { Pop-Location }
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# 离线安装 wujie 相关依赖(内网无网环境)。
|
||||
# bash ./install-offline.sh
|
||||
#
|
||||
# 依赖集(已全部打进同目录 .tgz,无其他传递依赖;vue 为已存在的 peer):
|
||||
# wujie-vue3@2.0.2 -> wujie@2.0.2 -> @babel/runtime@7.29.7
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJ="$(cd "$DIR/../.." && pwd)"
|
||||
|
||||
# 依赖在前、消费方在后,便于本地 tarball 互相满足 semver 范围(不触网)。
|
||||
TGZ=(
|
||||
"$DIR/babel-runtime-7.29.7.tgz"
|
||||
"$DIR/wujie-2.0.2.tgz"
|
||||
"$DIR/wujie-vue3-2.0.2.tgz"
|
||||
)
|
||||
|
||||
cd "$PROJ"
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
echo "[offline] 使用 pnpm 离线安装 wujie 依赖 ..."
|
||||
pnpm add --offline "${TGZ[@]}"
|
||||
else
|
||||
echo "[offline] 未找到 pnpm,改用 npm 离线安装 ..."
|
||||
npm install --offline "${TGZ[@]}"
|
||||
fi
|
||||
echo "[offline] 完成。可运行 'pnpm dev' / 'npm run dev' 验证。"
|
||||
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,9 @@
|
||||
},
|
||||
"./style.css": "./dist/parallel-agents-vue.css"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
@@ -26,7 +28,9 @@
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
"markdown-it": "^14.1.0",
|
||||
"wujie": "^2.0.2",
|
||||
"wujie-vue3": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
|
||||
Generated
+34
-225
@@ -11,19 +11,19 @@ importers:
|
||||
markdown-it:
|
||||
specifier: ^14.1.0
|
||||
version: 14.2.0
|
||||
wujie:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
wujie-vue3:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2(vue@3.5.35(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.0
|
||||
version: 4.3.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.4(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3))
|
||||
tailwindcss:
|
||||
specifier: ^4.1.0
|
||||
version: 4.3.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
@@ -52,6 +52,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -212,22 +216,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.61.1':
|
||||
resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==}
|
||||
cpu: [arm]
|
||||
@@ -366,100 +357,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.3.0':
|
||||
resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.3.0':
|
||||
resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.3.0':
|
||||
resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.3.0':
|
||||
resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
|
||||
resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
|
||||
resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.3.0':
|
||||
resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.3.0':
|
||||
resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/vite@4.3.0':
|
||||
resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
@@ -550,10 +447,6 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
enhanced-resolve@5.23.0:
|
||||
resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -584,9 +477,6 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
@@ -721,13 +611,6 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
tailwindcss@4.3.0:
|
||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||
|
||||
tapable@2.3.3:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -797,6 +680,14 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
wujie-vue3@2.0.2:
|
||||
resolution: {integrity: sha512-RypNFueT1z7Db/f44gfTrRAVJNj5Brq3httVgKpsEZ7UpCrNhjNZkBd+RX+XT4mNMB1L9t3fsO/DuqfhViT7YA==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
wujie@2.0.2:
|
||||
resolution: {integrity: sha512-K4SyJ1/frFOFJK4plE41UJ9Fe26pYGizemz2kXv3A0w+AcEoEVVU4mamHxXU1r/3jq3UXCragOacvPbg5XT9yg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
@@ -807,6 +698,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
@@ -890,25 +783,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.61.1':
|
||||
optional: true
|
||||
|
||||
@@ -984,74 +860,6 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.61.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.23.0
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.3.0
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.3.0':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.3.0
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.3.0
|
||||
'@tailwindcss/oxide-darwin-x64': 4.3.0
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.3.0
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.0
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.3.0
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.3.0
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.3.0
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.3.0
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.0
|
||||
|
||||
'@tailwindcss/vite@4.3.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.0
|
||||
'@tailwindcss/oxide': 4.3.0
|
||||
tailwindcss: 4.3.0
|
||||
vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
@@ -1166,12 +974,8 @@ snapshots:
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.23.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
detect-libc@2.1.2:
|
||||
optional: true
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
@@ -1215,11 +1019,10 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
jiti@2.7.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
@@ -1269,6 +1072,7 @@ snapshots:
|
||||
lightningcss-linux-x64-musl: 1.32.0
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
optional: true
|
||||
|
||||
linkify-it@5.0.1:
|
||||
dependencies:
|
||||
@@ -1344,10 +1148,6 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
tailwindcss@4.3.0: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -1387,3 +1187,12 @@ snapshots:
|
||||
'@vue/shared': 3.5.35
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
wujie-vue3@2.0.2(vue@3.5.35(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.35(typescript@5.9.3)
|
||||
wujie: 2.0.2
|
||||
|
||||
wujie@2.0.2:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
@@ -10,6 +10,48 @@ import { ParallelAgentsPanel } from "./parallel-agents";
|
||||
import type { AgentInput, AgentReply } from "./parallel-agents";
|
||||
|
||||
import { DEFAULT_BASE_URL } from "./parallel-agents/config";
|
||||
import { RoundtableDrawer } from "./roundtable-embed";
|
||||
import type { RoundtableConsensusPayload } from "./roundtable-embed";
|
||||
|
||||
// ── 顶部页签:在「并行面板 Demo」与「圆桌规划抽屉」之间切换 ──────────────
|
||||
const view = ref<"panel" | "roundtable">("panel");
|
||||
|
||||
// ── 圆桌规划抽屉(wujie 嵌入 DeerFlow)Demo 状态 ───────────────────────────
|
||||
const rt = reactive({
|
||||
// DeerFlow 前端站点源(无界子应用 URL 的源,非后端地址)。
|
||||
baseUrl: localStorage.getItem("rt.baseUrl") || "http://localhost:5174",
|
||||
username: localStorage.getItem("rt.username") || "demo",
|
||||
// 用户名+密码登录的共享口令(后端开启 require_password 时必填)。
|
||||
password: localStorage.getItem("rt.password") || "",
|
||||
taskTitle: localStorage.getItem("rt.taskTitle") || "",
|
||||
taskGoal: localStorage.getItem("rt.taskGoal") || "",
|
||||
chainId: localStorage.getItem("rt.chainId") || "",
|
||||
// 任务 id:聊天记录按此存取、打开时回显最新一条。
|
||||
taskId: localStorage.getItem("rt.taskId") || "",
|
||||
// 主题色:默认深蓝(dark)。
|
||||
theme: localStorage.getItem("rt.theme") || "dark",
|
||||
});
|
||||
const rtOpen = ref(false);
|
||||
const rtConsensus = ref<RoundtableConsensusPayload | null>(null);
|
||||
|
||||
function openRoundtable() {
|
||||
localStorage.setItem("rt.baseUrl", rt.baseUrl);
|
||||
localStorage.setItem("rt.username", rt.username);
|
||||
localStorage.setItem("rt.password", rt.password);
|
||||
localStorage.setItem("rt.taskTitle", rt.taskTitle);
|
||||
localStorage.setItem("rt.taskGoal", rt.taskGoal);
|
||||
localStorage.setItem("rt.chainId", rt.chainId);
|
||||
localStorage.setItem("rt.taskId", rt.taskId);
|
||||
localStorage.setItem("rt.theme", rt.theme);
|
||||
rtConsensus.value = null;
|
||||
rtOpen.value = true;
|
||||
}
|
||||
|
||||
function onRoundtableConsensus(payload: RoundtableConsensusPayload) {
|
||||
// 子应用第二步达成共识 → 收到各智能体交付。这里 demo 仅打印 + 展示。
|
||||
console.log("[demo] roundtable:consensus → 共识达成,各智能体交付:", payload);
|
||||
rtConsensus.value = payload;
|
||||
}
|
||||
|
||||
const cfg = reactive({
|
||||
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
|
||||
@@ -65,6 +107,24 @@ function launch() {
|
||||
|
||||
<template>
|
||||
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
|
||||
<!-- 顶部页签:并行面板 Demo / 圆桌规划抽屉 -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px">
|
||||
<button
|
||||
@click="view = 'panel'"
|
||||
:style="{ padding: '6px 14px', borderRadius: '6px', border: 0, cursor: 'pointer', background: view === 'panel' ? '#1677ff' : '#1a2742', color: '#e5edf7' }"
|
||||
>
|
||||
并行多智能体面板
|
||||
</button>
|
||||
<button
|
||||
@click="view = 'roundtable'"
|
||||
:style="{ padding: '6px 14px', borderRadius: '6px', border: 0, cursor: 'pointer', background: view === 'roundtable' ? '#1677ff' : '#1a2742', color: '#e5edf7' }"
|
||||
>
|
||||
圆桌规划抽屉(wujie)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── 页面一:并行多智能体面板 Demo ───────────────────────────────── -->
|
||||
<template v-if="view === 'panel'">
|
||||
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
|
||||
<label>后端 baseUrl
|
||||
@@ -154,5 +214,70 @@ function launch() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 页面二:圆桌规划抽屉(wujie 嵌入 DeerFlow) ──────────────────── -->
|
||||
<template v-else>
|
||||
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">圆桌规划抽屉 · wujie 嵌入 Demo</h1>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
|
||||
<label>DeerFlow 前端站点源(无界子应用 URL 的源)
|
||||
<input v-model="rt.baseUrl" placeholder="http://localhost:5174" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>用户名(决定登录哪个账号)
|
||||
<input v-model="rt.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>密码 / 口令(后端开启 require_password 时必填,单一共享口令)
|
||||
<input v-model="rt.password" type="password" placeholder="未开启门禁可留空" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>任务名称
|
||||
<input v-model="rt.taskTitle" placeholder="例如:XX 产品出海方案规划" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>任务描述
|
||||
<textarea v-model="rt.taskGoal" rows="3" placeholder="把背景、目标、约束写清楚" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px; resize: vertical" />
|
||||
</label>
|
||||
<label>指定业务链条 chainId(直传,子应用据此自动选链进第二步)
|
||||
<input v-model="rt.chainId" placeholder="例如 chain_xxx" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>任务 id(聊天记录按此存取;该任务有记录则打开时回显最新一条)
|
||||
<input v-model="rt.taskId" placeholder="例如 task-001" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>主题色(默认深蓝 dark)
|
||||
<select v-model="rt.theme" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px">
|
||||
<option value="dark">深蓝(dark)</option>
|
||||
<option value="light">浅色(light)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button @click="openRoundtable" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer">
|
||||
打开圆桌规划抽屉
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 共识回传结果(子应用第二步达成共识后回传各智能体交付) -->
|
||||
<div v-if="rtConsensus" style="margin-top: 16px; max-width: 880px; background: #0d1526; border: 1px solid #1e2d4d; border-radius: 8px; padding: 12px">
|
||||
<div style="font-weight: 700; color: #7ee787; margin-bottom: 8px">✅ 已收到共识回传(roundtable:consensus)</div>
|
||||
<div style="font-size: 13px; color: #9fb0c8; margin-bottom: 6px">
|
||||
链条:{{ rtConsensus.chainTitle || rtConsensus.chainId || "—" }}|总控:{{ rtConsensus.coordinatorName || "—" }}
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #cbd5e1; white-space: pre-wrap; margin-bottom: 8px">{{ rtConsensus.consensusText }}</div>
|
||||
<div v-for="d in rtConsensus.deliverables" :key="d.agentName" style="border-top: 1px solid #1e2d4d; padding-top: 8px; margin-top: 8px">
|
||||
<div style="font-weight: 600; color: #e5edf7">{{ d.agentName }}</div>
|
||||
<div style="font-size: 12px; color: #9fb0c8; white-space: pre-wrap">{{ d.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉本体:wujie 嵌入 DeerFlow 圆桌规划页 -->
|
||||
<RoundtableDrawer
|
||||
v-model:open="rtOpen"
|
||||
:base-url="rt.baseUrl"
|
||||
:username="rt.username || undefined"
|
||||
:password="rt.password || undefined"
|
||||
:task-title="rt.taskTitle || undefined"
|
||||
:task-goal="rt.taskGoal || undefined"
|
||||
:chain-id="rt.chainId || undefined"
|
||||
:task-id="rt.taskId || undefined"
|
||||
:theme="rt.theme"
|
||||
@consensus="onRoundtableConsensus"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 圆桌规划嵌入抽屉(宿主侧 / 父页面)。
|
||||
*
|
||||
* 用无界(wujie)把 DeerFlow 的「圆桌规划」页塞进一个右侧抽屉:
|
||||
* - 父 → 子:通过 `:props` 注入「任务名称 / 任务描述 / 指定业务链条 chainId / 用户名 / 主题」,
|
||||
* 子应用首帧从 `window.$wujie.props` 读取(见 DeerFlow `core/embed/wujie.ts`)。
|
||||
* 抽屉打开即自动跑第一步分析,点「进入多智能体研讨」按指定链条进第二步。
|
||||
* - 子 → 父:子应用第二步达成共识后 `bus.$emit('roundtable:consensus', payload)`,
|
||||
* 本组件监听后向宿主 `emit('consensus', payload)`,把各智能体交付交还父页面。
|
||||
*
|
||||
* 抽屉关闭即卸载子应用(`alive=false`),下次打开是干净的新实例、读最新 props。
|
||||
*/
|
||||
import { computed, onBeforeUnmount } from "vue";
|
||||
import WujieVue from "wujie-vue3";
|
||||
import { bus } from "wujie";
|
||||
|
||||
/** 单个智能体交付(与 DeerFlow `collectRoundtableDeliverables` 对齐)。 */
|
||||
export interface RoundtableDeliverable {
|
||||
agentName: string;
|
||||
content: string;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
/** 共识回传载荷(与 DeerFlow `emitWujieEvent("roundtable:consensus", …)` 对齐)。 */
|
||||
export interface RoundtableConsensusPayload {
|
||||
taskTitle: string | null;
|
||||
chainId: string | null;
|
||||
chainTitle: string | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
deliverables: RoundtableDeliverable[];
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 抽屉开关(v-model:open)。 */
|
||||
open: boolean;
|
||||
/** DeerFlow 前端站点源,如 http://localhost:5174。 */
|
||||
baseUrl: string;
|
||||
/** 任务名称(注入子应用 Step 1 首条消息)。 */
|
||||
taskTitle?: string;
|
||||
/** 任务描述(注入子应用 Step 1 首条消息)。 */
|
||||
taskGoal?: string;
|
||||
/** 指定业务链条 id(直传,子应用据此自动选链进第二步)。 */
|
||||
chainId?: string;
|
||||
/** 外部任务 id:聊天记录按此存取,打开时回显该任务最新一条记录。 */
|
||||
taskId?: string;
|
||||
/** 登录用户名(决定登录哪个账号)。 */
|
||||
username?: string;
|
||||
/** 用户名+密码登录的共享口令(后端开启 require_password 时必填)。 */
|
||||
password?: string;
|
||||
/** 主题色:默认 dark(深蓝)。 */
|
||||
theme?: string;
|
||||
}>(),
|
||||
{ theme: "dark" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "consensus", payload: RoundtableConsensusPayload): void;
|
||||
}>();
|
||||
|
||||
/** 无界子应用实例名(同名复用同一沙箱,这里抽屉级唯一即可)。 */
|
||||
const APP_NAME = "deerflow-roundtable";
|
||||
|
||||
/**
|
||||
* 子应用 URL:DeerFlow 前端站点 + 圆桌**嵌入**路由(只渲染主体、无侧边栏,用户名登录)。
|
||||
* 注意走 `/#/embed/roundtable`,而非带 workspace 侧边栏的 `/#/page/workspace/roundtable`。
|
||||
*/
|
||||
const subUrl = computed(() => {
|
||||
const origin = props.baseUrl.replace(/\/+$/, "");
|
||||
return `${origin}/#/embed/roundtable`;
|
||||
});
|
||||
|
||||
/** 注入子应用的 props(DeerFlow `readWujiePrefill()` 读取)。 */
|
||||
const wujieProps = computed(() => ({
|
||||
taskTitle: props.taskTitle,
|
||||
taskGoal: props.taskGoal,
|
||||
chainId: props.chainId,
|
||||
taskId: props.taskId,
|
||||
username: props.username,
|
||||
password: props.password,
|
||||
theme: props.theme,
|
||||
}));
|
||||
|
||||
function close() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
function onConsensus(payload: RoundtableConsensusPayload) {
|
||||
emit("consensus", payload);
|
||||
}
|
||||
|
||||
// 订阅子应用共识回传;组件卸载时退订(全局总线,避免重复监听)。
|
||||
bus.$on("roundtable:consensus", onConsensus);
|
||||
onBeforeUnmount(() => bus.$off("roundtable:consensus", onConsensus));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="rt-drawer-mask" @click.self="close">
|
||||
<aside class="rt-drawer" @click.stop>
|
||||
<header class="rt-drawer__head">
|
||||
<span class="rt-drawer__title">圆桌规划 · {{ taskTitle || "新任务" }}</span>
|
||||
<button class="rt-drawer__close" type="button" @click="close">✕</button>
|
||||
</header>
|
||||
<div class="rt-drawer__body">
|
||||
<WujieVue
|
||||
:name="APP_NAME"
|
||||
:url="subUrl"
|
||||
:props="wujieProps"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:sync="false"
|
||||
:alive="false"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rt-drawer-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.rt-drawer {
|
||||
width: 92vw;
|
||||
max-width: 1280px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25);
|
||||
animation: rt-slide-in 0.22s ease-out;
|
||||
}
|
||||
@keyframes rt-slide-in {
|
||||
from {
|
||||
transform: translateX(24px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.rt-drawer__head {
|
||||
flex: 0 0 auto;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.rt-drawer__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
.rt-drawer__close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rt-drawer__close:hover {
|
||||
background: #f2f2f2;
|
||||
color: #333;
|
||||
}
|
||||
.rt-drawer__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* 让无界容器链逐层铺满抽屉主体,子应用 #root 的 height:100% 才有确定父高度可解析。
|
||||
WujieVue 根 div 已由 width/height props 撑成 100%;这里再兜底注入的 web component
|
||||
主机 <wujie-app> 与退化 iframe,让链路不断。 */
|
||||
.rt-drawer__body :deep(wujie-app),
|
||||
.rt-drawer__body :deep(iframe) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as RoundtableDrawer } from "./RoundtableDrawer.vue";
|
||||
export type {
|
||||
RoundtableConsensusPayload,
|
||||
RoundtableDeliverable,
|
||||
} from "./RoundtableDrawer.vue";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Response> {
|
||||
return window.fetch(input, { ...init, credentials: "include" });
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user