Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	frontend-web/src/components/workspace/settings/memory-settings-page.tsx
#	frontend-web/src/core/auth/sync.tsx
This commit is contained in:
吉鑫升 committed 2026-06-04 17:49:26 +08:00
commit c1336ad5b3
21 files changed
+877 -181

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+2 -2
View File
@@ -2,8 +2,8 @@
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
VITE_BACKEND_BASE_URL=http://47.88.25.99
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99/api
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
+1
View File
@@ -25,6 +25,7 @@ VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+1
View File
@@ -64,6 +64,7 @@
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
@@ -767,7 +767,10 @@ export function ArtifactFilePreview({
return;
}
const blob = new Blob([content ?? ""], { type: "text/html" });
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url);
@@ -1,6 +1,6 @@
"use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -464,23 +464,41 @@ const HISTORY_LABELS: Record<string, string> = {
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: {
preference: "偏好",
knowledge: "知识",
context: "背景",
behavior: "行为",
goal: "目标",
correction: "纠偏",
},
en: {
preference: "Preference",
knowledge: "Knowledge",
context: "Context",
behavior: "Behavior",
goal: "Goal",
correction: "Correction",
},
};
/** 事实分类的中文标签。 */
const CATEGORY_LABELS: Record<string, string> = {
preference: "偏好",
knowledge: "知识",
context: "背景",
behavior: "行为",
goal: "目标",
correction: "纠正",
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-50 text-blue-700 border border-blue-200/50 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-900/30",
knowledge: "bg-green-50 text-green-700 border border-green-200/50 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900/30",
context: "bg-amber-50 text-amber-700 border border-amber-200/50 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-900/30",
behavior: "bg-purple-50 text-purple-700 border border-purple-200/50 dark:bg-purple-950/30 dark:text-purple-400 dark:border-purple-900/30",
goal: "bg-orange-50 text-orange-700 border border-orange-200/50 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/30",
correction: "bg-red-50 text-red-700 border border-red-200/50 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900/30",
};
const CATEGORY_ICONS: Record<string, React.ElementType> = {
preference: Heart,
knowledge: BookOpen,
context: Map,
behavior: Zap,
goal: Target,
correction: ShieldAlert,
};
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
@@ -494,6 +512,9 @@ function MemoryV1View({
memory_usage: string;
};
}) {
const { locale } = useI18n();
const isZh = locale.startsWith("zh");
const labelMap = isZh ? CATEGORY_LABELS.zh : CATEGORY_LABELS.en;
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
@@ -504,9 +525,8 @@ function MemoryV1View({
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div>
<Tabs defaultValue="profile">
<Tabs defaultValue="facts">
<TabsList>
<TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
<TabsTrigger value="facts">
事实记录
{memory.facts.length > 0 && (
@@ -515,8 +535,81 @@ function MemoryV1View({
</span>
)}
</TabsTrigger>
<TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
</TabsList>
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pt-2">
{memory.facts.map((fact) => {
const categoryLabel = labelMap[fact.category] ?? fact.category;
const Icon = CATEGORY_ICONS[fact.category] ?? PlusIcon;
return (
<div
key={fact.id}
className="group relative flex flex-col justify-between rounded-xl border border-border/40 bg-gradient-to-br from-card to-card/50 hover:from-card hover:to-card hover:border-primary/30 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 p-5 space-y-4 dark:border-[#213D5E]/40 dark:from-[#022248]/40 dark:to-[#022248]/10 dark:hover:from-[#022248]/60 dark:hover:to-[#022248]/30 overflow-hidden"
>
{/* Subtle top highlight */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border shadow-sm ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700 border-gray-200"
}`}
>
<Icon className="w-3.5 h-3.5" />
{categoryLabel}
</span>
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
{Math.round(fact.confidence * 100)}%
</span>
{/* Confidence Progress Bar */}
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
</div>
</div>
</div>
<p className="text-[14px] text-foreground/90 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
{desensitize(fact.content)}
</p>
</div>
{fact.sourceError && (
<div className="mt-auto pt-2">
<div className="rounded-lg bg-red-50/80 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50 p-3 text-xs text-red-600 dark:text-red-400 shadow-sm">
<div className="flex items-center gap-1.5 font-semibold mb-1">
<ShieldAlert className="w-3.5 h-3.5" />
<span>避免</span>
</div>
<p className="leading-relaxed opacity-90">{desensitize(fact.sourceError)}</p>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
{/* 用户画像 (userContext) */}
@@ -557,49 +650,6 @@ function MemoryV1View({
)}
</div>
</TabsContent>
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{memory.facts.map((fact) => (
<div
key={fact.id}
className="flex flex-col gap-2 rounded-lg border p-3 shadow-sm dark:border-[#213D5E]"
>
<div className="flex items-center justify-between gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{CATEGORY_LABELS[fact.category] ?? fact.category}
</span>
<span className="shrink-0 text-xs font-medium text-muted-foreground">
置信度 {Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
@@ -29,6 +29,13 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -45,6 +52,7 @@ import {
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Item,
ItemActions,
@@ -358,6 +366,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{/* —— 原纵向列表布局(保留备查)——
{!isLoading && !error &&
filteredSkills.length > 0 &&
pagedSkills.map((skill) => (
@@ -367,7 +377,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
))} */}
{/* card */}
{!isLoading && !error && filteredSkills.length > 0 && (
<div className="grid grid-cols-1 items-stretch gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
className="h-full"
/>
))}
</div>
)}
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
@@ -420,10 +444,12 @@ export function SkillRow({
skill,
currentUserId,
isAdmin,
className,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
className?: string;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
@@ -509,6 +535,8 @@ export function SkillRow({
return (
<>
{/* —— 原 Item 列表行布局(保留备查)——
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
@@ -676,7 +704,205 @@ export function SkillRow({
</Button>
)}
</ItemActions>
</Item>
</Item> */}
<Card
className={cn(
"group flex h-full min-w-0 cursor-pointer flex-col transition-shadow hover:shadow-md dark:!border-[#213D5E] dark:bg-[#02244C] dark:text-white",
className,
)}
onClick={() => setDetailOpen(true)}
>
<CardHeader className="min-w-0 flex flex-col gap-0">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="h-5 w-5" />
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 overflow-hidden">
<CardTitle
className="min-w-0 flex-1 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(skill.name)}
>
{desensitize(skill.name)}
</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{isBuiltin
? t.settings.skills.badgeBuiltin
: skill.category}
</Badge>
</div>
</div>
<div className="mt-4 mb-1.5 flex min-h-5 flex-wrap items-center gap-1">
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
<div className="mt-1.5 min-h-10 mb-3">
{skill.description ? (
<CardDescription className="line-clamp-2 text-sm">
{desensitize(skill.description)}
</CardDescription>
) : null}
</div>
<div className="text-muted-foreground mt-0.5 min-h-4 text-xs leading-tight">
{skill.use_count !== undefined && skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
<div
className={cn(
"min-w-0 max-w-full overflow-hidden pb-0",
(skill.tags?.length ?? 0) > 0 ? "pt-2.5" : "min-h-4",
)}
>
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="w-full" />
)}
</div>
</CardHeader>
<CardFooter
className="cursor-default py-0 pt-1.5"
onClick={(e) => e.stopPropagation()}
>
<div className="flex w-full flex-wrap items-center justify-end gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="h-3.5 w-3.5" />
) : (
<PinIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardFooter>
</Card>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
@@ -17,20 +17,22 @@ export function TagChip({
className?: string;
}) {
const color = resolveTagColor(tag.color);
const label = desensitize(tag.name);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
"inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
title={label}
>
{desensitize(tag.name)}
<span className="min-w-0 truncate">{label}</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
className="shrink-0 hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
@@ -50,7 +52,7 @@ export function TagChipList({
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
<div className={cn("flex min-w-0 max-w-full flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
+40 -20
View File
@@ -1,50 +1,70 @@
"use client";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200;
const IDENTITY_TOAST_ID = "auth-identity-changed";
function readUserId(): string | null {
return getStoredAuth()?.user_id ?? null;
}
/**
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
* cross-tab changes — when another tab logs in as a different user, this tab
* still holds the previous user's cached state (chat list, thread ids, etc.)
* which then collides with the new token and surfaces as a 404 on "新对话".
* Reloading the tab is the simplest way to resync React state with the new
* identity.
* Mount once at the app root. Watches localStorage auth changes from other
* tabs and lets the user decide when to refresh on account switches.
*/
export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
useEffect(() => {
function handleStorage(event: StorageEvent) {
// Only react to explicit changes of the auth key. Ignore unrelated keys
// AND `localStorage.clear()` (event.key === null) — embedded sub-apps /
// iframes wiping their own storage used to spuriously log the user out
// here even though the backend session was still valid. Genuine session
// loss is now detected centrally via 401 responses in apiFetch.
if (event.key !== AUTH_STORAGE_KEY) {
return;
}
let settleTimer: ReturnType<typeof setTimeout> | null = null;
function evaluateSettledIdentity() {
const nextUserId = readUserId();
if (nextUserId === lastUserIdRef.current) {
return;
}
lastUserIdRef.current = nextUserId;
// Only resync on a genuine account switch in another tab (different,
// non-null user). Do NOT force a login redirect on null here.
if (nextUserId !== null) {
window.location.reload();
if (nextUserId === null) {
return;
}
toast.warning("检测到账号在其它标签页发生变化", {
id: IDENTITY_TOAST_ID,
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
duration: Infinity,
action: {
label: "刷新同步",
onClick: () => window.location.reload(),
},
});
}
function handleStorage(event: StorageEvent) {
// Ignore unrelated keys and localStorage.clear(). Backend session loss is
// handled centrally through 401 responses.
if (event.key !== AUTH_STORAGE_KEY) {
return;
}
if (settleTimer) {
clearTimeout(settleTimer);
}
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
return () => {
window.removeEventListener("storage", handleStorage);
if (settleTimer) {
clearTimeout(settleTimer);
}
};
}, []);
return null;
+36 -30
View File
@@ -621,10 +621,15 @@ export function useThreadStream({
lockThreadRagMode(threadId);
}
await thread.submit(
{
messages: [
{
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
@@ -642,32 +647,33 @@ export function useThreadStream({
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...extraContext,
...context,
thinking_enabled: context.mode !== "flash",
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
subagent_enabled: context.mode === "ultra",
reasoning_effort:
context.reasoning_effort ??
(context.mode === "ultra"
? "high"
: context.mode === "pro"
? "medium"
: context.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
reasoning_effort:
mergedContext.reasoning_effort ??
(mergedContext.mode === "ultra"
? "high"
: mergedContext.mode === "pro"
? "medium"
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -102,17 +102,99 @@ import {
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单
const SETUP_CHAT_ID = 'aw-setup-chat';
const SETUP_FORM_ID = 'aw-setup-form';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35)
const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
/**
* 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
* 一旦开始写作(status !== idle)就切回草稿预览。
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
*/
function AIWritingRightPanel() {
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
return (
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
);
}
/**
* 根据写作状态切换两套左右布局:
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
*/
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
return state.status === 'idle' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
if (state.status === 'idle') {
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={SETUP_LAYOUT}
className="h-full"
>
{/* 左侧:写作助手 Q&A */}
<ResizablePanel
id={SETUP_CHAT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<WritingSetupChat />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:写作配置选项表单 */}
<ResizablePanel
id={SETUP_FORM_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
);
}
export default function AIWritingPage({
@@ -120,8 +202,6 @@ export default function AIWritingPage({
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
@@ -131,37 +211,7 @@ export default function AIWritingPage({
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingRightPanel />
</ResizablePanel>
</ResizablePanelGroup>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
</div>
</div>
</TooltipProvider>
+37 -2
View File
@@ -33,6 +33,15 @@ import {
} from "./iframe-chat-submit";
type BrainyChatStatus = "started" | "finished";
type BrainyChatInterruptMessage = {
source?: string;
type?: string;
payload?: {
reason?: string;
requestId?: string;
};
requestId?: string;
};
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
window.parent?.postMessage(
@@ -206,10 +215,34 @@ export default function IframeChatPage() {
[sendMessage, threadId],
);
const handleInterrupt = useCallback(
async (message: BrainyChatInterruptMessage) => {
const targetRequestId = message.payload?.requestId?.trim();
if (targetRequestId) {
pendingRequestIdRef.current = targetRequestId;
}
console.log("收到插件中断消息", {
reason: message.payload?.reason,
requestId: message.requestId,
targetRequestId,
});
await thread.stop();
},
[thread],
);
useEffect(() => {
const handleMessage = (event: MessageEvent<BrainyChatSubmitMessage>) => {
const handleMessage = (
event: MessageEvent<BrainyChatSubmitMessage | BrainyChatInterruptMessage>,
) => {
const data = event.data;
if (data?.source !== "brainy-extension") return;
if (data?.type === "brainy:chat-interrupt") {
void handleInterrupt(data);
return;
}
if (data?.type !== "brainy:chat-submit") return;
const { submitText, files, iframeContext, additionalKwargs } =
@@ -220,8 +253,10 @@ export default function IframeChatPage() {
prompt: data.payload?.prompt,
pageContext: data.payload?.pageContext,
screenshot: data.payload?.screenshot,
model: data.payload?.model,
requestId: data.requestId,
filesCount: files.length,
iframeContext,
});
if (!submitText && files.length === 0) return;
@@ -239,7 +274,7 @@ export default function IframeChatPage() {
return () => {
window.removeEventListener("message", handleMessage);
};
}, [handleSubmit]);
}, [handleInterrupt, handleSubmit]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
@@ -79,3 +79,24 @@ test("keeps screenshot-only iframe submissions in the conversation flow", () =>
assert.equal(result.files[0]?.filename, "shot.jpg");
assert.equal(result.files[0]?.mediaType, "image/jpeg");
});
test("maps iframe model selection into stream context model_name", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
requestId: "iframe-456",
payload: {
text: "帮我总结这个页面",
model: {
label: "Qwen3-32B: 效果与速度均衡(推荐)",
value: "OpenAI3",
},
},
});
assert.equal(result.submitText, "帮我总结这个页面");
assert.deepEqual(result.iframeContext, {
brainy_request_id: "iframe-456",
model_name: "OpenAI3",
});
});
+16 -3
View File
@@ -21,6 +21,11 @@ type BrainyScreenshot = {
images?: BrainyScreenshotImage[];
};
type BrainyModelSelection = {
label?: string;
value?: string;
};
export type BrainyChatSubmitMessage = {
source?: string;
type?: string;
@@ -29,6 +34,7 @@ export type BrainyChatSubmitMessage = {
prompt?: string;
pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot;
model?: BrainyModelSelection;
};
requestId?: string;
};
@@ -261,13 +267,20 @@ export function parseBrainyChatSubmitMessage(
hasAttachedImage: files.length > 0,
});
const requestId = trimToUndefined(data.requestId);
const modelName = trimToUndefined(data.payload?.model?.value);
const iframeContext: Record<string, unknown> = {};
if (requestId) {
iframeContext.brainy_request_id = requestId;
}
if (modelName) {
iframeContext.model_name = modelName;
}
return {
submitText,
files,
...(requestId
? { iframeContext: { brainy_request_id: requestId } }
: {}),
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
};
}
@@ -0,0 +1,177 @@
/**
* 业务链条列表弹窗(圆桌页顶栏「业务链条」按钮入口)。
*
* 点击「业务链条」按钮后打开本弹窗:展示当前用户已配置的业务链条卡片列表。
* - 每张卡片右侧「设置」按钮 → 进入配置页并**切换到该链条**(带 `chainId`)。
* - footer「配置」按钮 → 进入配置页(空白新建,等价于旧按钮的直接跳转)。
*
* 纯导航 / 浏览用途,不参与研讨席位选择(席位选择走 `BusinessChainPicker`)。
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
* 会看到实时数据。
*/
import { Fragment, useEffect, useState } from "react";
import {
ArrowRightIcon,
LayersIcon,
Loader2Icon,
SettingsIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { listChains } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainListDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
onConfigure: (chainId?: string) => void;
}
export function BusinessChainListDialog({
open,
onOpenChange,
onConfigure,
}: BusinessChainListDialogProps) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const list = await listChains();
if (!cancelled) setChains(list);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<LayersIcon className="size-5 text-primary" />
业务链条
</DialogTitle>
<DialogDescription>
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
{loading ? (
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
正在加载业务链条…
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
加载业务链条失败:{error}
</div>
) : chains.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">还没有业务链条</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。
</p>
</div>
) : (
<div className="space-y-3.5">
{chains.map((chain) => (
<ChainRow
key={chain.id}
chain={chain}
onSettings={() => onConfigure(chain.id)}
/>
))}
</div>
)}
</div>
<DialogFooter className="shrink-0 border-t pt-3 sm:justify-end">
<Button onClick={() => onConfigure()} className="gap-1.5">
<SettingsIcon className="size-4" />
配置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 单张链条行卡片 ─────────────────────────────────────────────────────────
function ChainRow({
chain,
onSettings,
}: {
chain: BusinessChain;
onSettings: () => void;
}) {
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 设置按钮 */}
<div className="flex items-center gap-2.5">
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{chain.seats.length} 席
</span>
<Button
variant="ghost"
size="sm"
onClick={onSettings}
className="ml-auto h-8 shrink-0 gap-1.5 px-3 text-sm"
>
<SettingsIcon className="size-4" />
设置
</Button>
</div>
{chain.description && (
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
)}
{/* 席位名 tag(按顺序,→ 箭头连接发言次序) */}
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
{chain.seats.map((seat, i) => (
<Fragment key={seat.agent_id}>
{i > 0 && <ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />}
<span
className={cn(
"rounded-md border px-2 py-1 text-xs font-medium",
chainTagClasses(getAvatarTypeFor(seat.agent_id, i)),
)}
>
{seat.name || seat.agent_id}
</span>
</Fragment>
))}
</div>
</div>
);
}
@@ -14,6 +14,7 @@ import {
Sparkles,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
@@ -118,6 +119,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const [drawMode, setDrawMode] = useState(false);
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
const { deselect: deselectArtifact } = useArtifacts();
useEffect(() => {
deselectArtifact();
}, [deselectArtifact]);
// Build the per-seat list (excludes the coordinator). useMemo so the file
// fetch effect doesn't re-fire on every parent render.
const seatThreadEntries = useMemo(() => {
@@ -521,7 +521,7 @@ function PresentFilesRow({
disabled: boolean;
}) {
return (
<div className="mt-2 flex flex-wrap gap-2">
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
{files.map((path) => (
<PresentFileCard
key={path}
@@ -575,18 +575,18 @@ function PresentFileCard({
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-start gap-2">
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
className="min-w-0 flex-1 truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
{typeLabel}
</span>
@@ -17,8 +17,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
buildArtifactDownloadUrl,
initReportThread,
listSessionArtifacts,
streamMultiAgent,
@@ -139,6 +141,11 @@ function buildReportPrompt(opts: {
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
);
// 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。
parts.push(
"硬性要求:① 全程使用简体中文输出——HTML 正文、标题、图表标签与图例、按钮、结论摘要等一切文字都必须是中文,禁止输出英文(专有名词可中英并列);" +
'② HTML 必须是完整文档,并在 <head> 最前面声明 <meta charset="utf-8">,确保中文不乱码。',
);
return parts.join("\n");
}
@@ -543,6 +550,51 @@ export function useStep3Report(
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实文件为准回灌 stub,拿到完整 HTML。
// 智能体可能分多次写文件 —— 尤其是 write_file 带 `append=true` 续写后半段(截图里
// 「Append remaining HTML content…」那一步)。后端 append 会把后半段拼到磁盘文件已有
// 内容后面,磁盘文件是完整的;但前端 stub 按 (name,path) 去重,第二次 write_file 会用
// 「只含后半段」的 content 覆盖掉前半段(防抹空守卫只挡空内容、挡不住非空的后半段)。
// 这里流式结束后从 outputs 读真实文件回灌 stub,让预览与存档都拿到完整 HTML。
// 返回最终用于存草稿的 HTML(拉取失败 / 真实文件更短时退回 stub 内容,不打断收尾)。
const reconcileStubFromRealFile = useCallback(
async (threadId: string, signal?: AbortSignal): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const stubPath =
typeof (writeTc?.args as { path?: unknown })?.path === "string"
? ((writeTc!.args as { path?: string }).path as string)
: "";
const stubContent =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
if (!stubPath) return stubContent;
try {
const list = await listSessionArtifacts(threadId, { signal });
const htmlFile =
list.files.find((f) => /\.html?$/i.test(f.name)) ?? null;
if (!htmlFile) return stubContent;
const res = await apiFetch(
buildArtifactDownloadUrl(threadId, htmlFile.path),
{ signal },
);
if (!res.ok) return stubContent;
const real = await res.text();
// 只在真实文件更长(说明 stub 被后半段截短了)时替换,避免偶发空/短响应回退。
if (real.trim() && real.length > stubContent.length) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取真实文件失败就退回 stub 内容,不打断主流程 */
}
return stubContent;
},
[upsertStub],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
@@ -604,17 +656,16 @@ export function useStep3Report(
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。从 stub 取最新 write_file 内容。
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const html =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。
if (html.trim()) {
onReportSavedRef.current?.({
html,
@@ -645,6 +696,7 @@ export function useStep3Report(
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
]);
const start = useCallback(() => {
@@ -21,8 +21,8 @@
*
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
import {
@@ -101,6 +101,7 @@ type ActiveDrag =
export default function BusinessChainEditorPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]);
@@ -184,6 +185,19 @@ export default function BusinessChainEditorPage() {
setSeats([]);
}, []);
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
// 只在链条列表加载完后应用一次(用 ref 闸门避免覆盖用户后续的手动切换)。
const appliedChainParamRef = useRef(false);
useEffect(() => {
if (appliedChainParamRef.current) return;
const chainId = searchParams.get("chainId");
if (!chainId) return;
if (chains.length === 0) return; // 等 refreshChains 拉回列表
const chain = chains.find((c) => c.id === chainId);
if (chain) loadChain(chain);
appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试
}, [chains, searchParams, loadChain]);
// ─── 席位增删改 ───────────────────────────────────────────────────────
const addAgentToSeats = useCallback(
(agentId: string, atIndex?: number) => {
@@ -55,6 +55,7 @@ import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -85,6 +86,8 @@ export default function RoundtablePlanningPage() {
const [currentStep, setCurrentStep] = useState<number>(1);
const [showToast, setShowToast] = useState<string | null>(null);
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
// 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -374,6 +377,19 @@ export default function RoundtablePlanningPage() {
onConfirm={handleConfirmAgents}
/>
<BusinessChainListDialog
open={isChainListOpen}
onOpenChange={setIsChainListOpen}
onConfigure={(chainId) => {
setIsChainListOpen(false);
navigate(
chainId
? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}`
: "/page/workspace/roundtable/chains",
);
}}
/>
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -394,8 +410,8 @@ export default function RoundtablePlanningPage() {
<div className="flex items-center gap-2">
<button
id="business-chain-btn"
onClick={() => navigate("/page/workspace/roundtable/chains")}
title="配置可复用的业务链条(有序智能体编排)"
onClick={() => setIsChainListOpen(true)}
title="查看 / 配置可复用的业务链条(有序智能体编排)"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<ListOrdered className="w-3.5 h-3.5 text-primary" />
@@ -30,7 +30,7 @@
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — |
| ① | 报告头 | 标题 + 生成时间占位 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
@@ -73,4 +73,5 @@
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的数据