wujie应用联调

This commit is contained in:
董宏凯 committed 2026-06-11 17:11:28 +08:00
1 parent 0784f30e23
commit f3ca996116
35 files changed
+1590 -334

No files matched your search

+125
View File
@@ -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}`;
}