diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 1d8b3666..0e89436f 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -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 diff --git a/frontend-web/.env.network b/frontend-web/.env.network index 4bcd8f15..be82f968 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -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 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 67dabce1..6aa2d33d 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -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= diff --git a/frontend-web/package.json b/frontend-web/package.json index a5921463..bc5cbd2e 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -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", diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 32a16a17..ef9416f2 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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 若没写 + // 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 + const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" }); const url = URL.createObjectURL(blob); setHtmlPreviewUrl(url); diff --git a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx index 8bf7ef04..477f039e 100644 --- a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx @@ -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 = { longTermBackground: "长期背景", }; -const CATEGORY_COLORS: Record = { - 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> = { + zh: { + preference: "偏好", + knowledge: "知识", + context: "背景", + behavior: "行为", + goal: "目标", + correction: "纠偏", + }, + en: { + preference: "Preference", + knowledge: "Knowledge", + context: "Context", + behavior: "Behavior", + goal: "Goal", + correction: "Correction", + }, }; -/** 事实分类的中文标签。 */ -const CATEGORY_LABELS: Record = { - preference: "偏好", - knowledge: "知识", - context: "背景", - behavior: "行为", - goal: "目标", - correction: "纠正", +const CATEGORY_COLORS: Record = { + 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 = { + 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 模式:记忆由系统在对话结束后自动提取,无需手动管理。 - + - 用户画像 & 历史 事实记录 {memory.facts.length > 0 && ( @@ -515,8 +535,81 @@ function MemoryV1View({ )} + 用户画像 & 历史 + {/* 事实记录 Tab */} + + + + 事实记录 + {memory.memory_usage} + + {memory.facts.length === 0 ? ( + + 暂无事实记录,对话后系统会自动提取。 + + ) : ( + + {memory.facts.map((fact) => { + const categoryLabel = labelMap[fact.category] ?? fact.category; + const Icon = CATEGORY_ICONS[fact.category] ?? PlusIcon; + return ( + + {/* Subtle top highlight */} + + + + + + + {categoryLabel} + + + + {Math.round(fact.confidence * 100)}% + + {/* Confidence Progress Bar */} + + + + + + + + {desensitize(fact.content)} + + + + {fact.sourceError && ( + + + + + 避免 + + {desensitize(fact.sourceError)} + + + )} + + ); + })} + + )} + + + {/* 用户画像 & 历史摘要 Tab */} {/* 用户画像 (userContext) */} @@ -557,49 +650,6 @@ function MemoryV1View({ )} - - {/* 事实记录 Tab */} - - - - 事实记录 - {memory.memory_usage} - - {memory.facts.length === 0 ? ( - - 暂无事实记录,对话后系统会自动提取。 - - ) : ( - - {memory.facts.map((fact) => ( - - - - {CATEGORY_LABELS[fact.category] ?? fact.category} - - - 置信度 {Math.round(fact.confidence * 100)}% - - - {desensitize(fact.content)} - {fact.sourceError && ( - - 避免:{desensitize(fact.sourceError)} - - )} - - ))} - - )} - - ); diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 260f0095..5f114792 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -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 && ( )} + + {/* —— 原纵向列表布局(保留备查)—— {!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 && ( + + {pagedSkills.map((skill) => ( + + ))} + + )} {!isLoading && !error && totalPages > 1 && ( + + {/* —— 原 Item 列表行布局(保留备查)—— )} - + */} + + setDetailOpen(true)} + > + + + + + + + + {desensitize(skill.name)} + + + {isBuiltin + ? t.settings.skills.badgeBuiltin + : skill.category} + + + + + + {isOwned && ( + + {t.settings.skills.badgeMine} + + )} + {isFromOthers && ( + + {t.settings.skills.badgeFromOthers} + + )} + {isOwned && skill.published && ( + + + {t.settings.skills.badgePublished} + + )} + {isCustom && !isBuiltin && skill.source === "agent" && ( + + {t.settings.skills.badgeSourceAgent} + + )} + {isCustom && !isBuiltin && skill.source === "upload" && ( + + {t.settings.skills.badgeSourceUpload} + + )} + {skill.pinned && ( + + {t.settings.skills.badgePinned} + + )} + + + {skill.description ? ( + + {desensitize(skill.description)} + + ) : null} + + + {skill.use_count !== undefined && skill.use_count > 0 + ? t.settings.skills.useCountInline.replace( + "{count}", + String(skill.use_count), + ) + : t.settings.skills.useCountInlineZero} + + 0 ? "pt-2.5" : "min-h-4", + )} + > + {(skill.tags?.length ?? 0) > 0 && ( + + )} + + + + e.stopPropagation()} + > + + {isAdmin && ( + setTagPickerOpen(true)} + title={t.adminTags.manageTags} + > + + + )} + {canManage && ( + void handleTogglePinned()} + title={ + skill.pinned + ? t.settings.skills.unpinAction + : t.settings.skills.pinAction + } + > + {skill.pinned ? ( + + ) : ( + + )} + + )} + {canTogglePublish && ( + void handleTogglePublished(!skill.published)} + title={ + skill.published + ? t.settings.skills.unpublishAction + : t.settings.skills.publishAction + } + > + {skill.published ? ( + + ) : ( + + )} + + )} + + + + {canManage && ( + setConfirmDeleteOpen(true)} + title={t.settings.skills.deleteAction} + > + + + )} + + + diff --git a/frontend-web/src/components/workspace/tags/tag-chip.tsx b/frontend-web/src/components/workspace/tags/tag-chip.tsx index 17edf948..635e46a6 100644 --- a/frontend-web/src/components/workspace/tags/tag-chip.tsx +++ b/frontend-web/src/components/workspace/tags/tag-chip.tsx @@ -17,20 +17,22 @@ export function TagChip({ className?: string; }) { const color = resolveTagColor(tag.color); + const label = desensitize(tag.name); return ( - {desensitize(tag.name)} + {label} {onRemove && ( @@ -50,7 +52,7 @@ export function TagChipList({ }) { if (!tags || tags.length === 0) return null; return ( - + {tags.map((tag) => ( ))} diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index 880f8824..4e74eb6d 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -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(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 | 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; diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 06c82d70..983bbf3b 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -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"] }); diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index cd96dc1a..d4ab02b9 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -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 正确解析 + + + + ); +} + +/** + * 根据写作状态切换两套左右布局: + * - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单; + * - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。 + */ +function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) { + const layoutRef = useRef(null); const { state } = useAIWriting(); - return state.status === 'idle' ? : ; + + if (state.status === 'idle') { + return ( + + {/* 左侧:写作助手 Q&A */} + + + + + + + {/* 右侧:写作配置选项表单 */} + + + + + ); + } + + return ( + + {/* 左侧:AI 写作面板,与原画布页左侧等宽 */} + + + + + + + {/* 右侧:草稿预览面板 */} + + + + + ); } export default function AIWritingPage({ @@ -120,8 +202,6 @@ export default function AIWritingPage({ }: { hideAdvancedFields?: boolean; } = {}) { - const layoutRef = useRef(null); - return ( @@ -131,37 +211,7 @@ export default function AIWritingPage({ - - {/* 左侧:AI 写作面板,与原画布页左侧等宽 */} - - {/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */} - - - - - - - - {/* 右侧:草稿预览面板 */} - - - - + diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index 770e86bf..602491ba 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -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) => { + const handleMessage = ( + event: MessageEvent, + ) => { 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 + diff --git a/frontend-web/src/pages/iframe-chat-submit.test.ts b/frontend-web/src/pages/iframe-chat-submit.test.ts index 517eab42..df236068 100644 --- a/frontend-web/src/pages/iframe-chat-submit.test.ts +++ b/frontend-web/src/pages/iframe-chat-submit.test.ts @@ -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", + }); +}); diff --git a/frontend-web/src/pages/iframe-chat-submit.ts b/frontend-web/src/pages/iframe-chat-submit.ts index 2396a046..094e05f6 100644 --- a/frontend-web/src/pages/iframe-chat-submit.ts +++ b/frontend-web/src/pages/iframe-chat-submit.ts @@ -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 = {}; + 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 } } : {}), }; } diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx new file mode 100644 index 00000000..d17e6e09 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + + + 业务链条 + + + 可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。 + + + + + {loading ? ( + + + 正在加载业务链条… + + ) : error ? ( + + 加载业务链条失败:{error} + + ) : chains.length === 0 ? ( + + + 还没有业务链条 + + 业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。 + + + ) : ( + + {chains.map((chain) => ( + onConfigure(chain.id)} + /> + ))} + + )} + + + + onConfigure()} className="gap-1.5"> + + 配置 + + + + + ); +} + +// ── 单张链条行卡片 ───────────────────────────────────────────────────────── + +function ChainRow({ + chain, + onSettings, +}: { + chain: BusinessChain; + onSettings: () => void; +}) { + return ( + + {/* 标题行 + 设置按钮 */} + + {chain.title || "未命名链条"} + + {chain.seats.length} 席 + + + + 设置 + + + + {chain.description && ( + {chain.description} + )} + + {/* 席位名 tag(按顺序,→ 箭头连接发言次序) */} + + {chain.seats.map((seat, i) => ( + + {i > 0 && } + + {seat.name || seat.agent_id} + + + ))} + + + ); +} diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 8cb7a0d0..cd5e88ea 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -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 = ({ }) => { // 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 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(() => { diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index c3cc6206..fc077524 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -521,7 +521,7 @@ function PresentFilesRow({ disabled: boolean; }) { return ( - + {files.map((path) => ( - + {filename} - + {typeLabel} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts index 6d2bf725..72f05b81 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -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 必须是完整文档,并在 最前面声明 ,确保中文不乱码。', + ); 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 => { + 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(() => { diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 94354ac9..2ed3f44f 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -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([]); @@ -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) => { diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index ac457087..2910c69e 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -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(1); const [showToast, setShowToast] = useState(null); const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState(false); + // 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。 + const [isChainListOpen, setIsChainListOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(""); // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。 // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 @@ -374,6 +377,19 @@ export default function RoundtablePlanningPage() { onConfirm={handleConfirmAgents} /> + { + setIsChainListOpen(false); + navigate( + chainId + ? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}` + : "/page/workspace/roundtable/chains", + ); + }} + /> + {showToast && ( @@ -394,8 +410,8 @@ export default function RoundtablePlanningPage() { 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" > diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md index 933bc760..30756662 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md @@ -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」之类的标签、徽章或水印 - ❌ 编造与原料明显矛盾的数据
+ {desensitize(fact.content)} +
{desensitize(fact.sourceError)}
{desensitize(fact.content)}
- 避免:{desensitize(fact.sourceError)} -
+ 业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。 +