代码提交

This commit is contained in:
董宏凯 committed 2026-07-24 10:33:28 +08:00
1 parent f49def09ea
commit 45df500a9c
55 files changed
+3390 -461

No files matched your search

+35 -7
View File
@@ -37,6 +37,11 @@ import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
} from "@/position-roundtable/demo/position-roundtable-demo-config";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
@@ -46,6 +51,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const publicEmbedTheme = usePublicEmbedTheme();
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
useEffect(() => {
@@ -54,8 +60,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
if (positionStandaloneMode) return;
void fetchYLogConfig();
}, []);
}, [positionStandaloneMode]);
const forcedTheme = useMemo(() => {
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
@@ -86,7 +93,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
attribute="class"
storageKey="strategy-theme"
defaultTheme="light"
themes={["light", "dark", "lightblue", "lightgreen"]}
themes={["light", "dark", "lightblue", "lightgreen", "iceblue"]}
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
@@ -95,8 +102,8 @@ function AppProviders({ children }: { children: React.ReactNode }) {
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
<AuthSyncWatcher />
<DesensitizeWordsLoader />
{!positionStandaloneMode && <AuthSyncWatcher />}
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
@@ -114,17 +121,38 @@ function AppProviders({ children }: { children: React.ReactNode }) {
*/
function RootRedirect() {
const { search } = useLocation();
if (isPositionRoundtableStandaloneMode()) {
return <Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />;
}
return <Navigate to={`/login${search}`} replace />;
}
export function App() {
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
const standaloneLoginRedirect = (
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
);
const pageElement = positionStandaloneMode ? (
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
) : (
<PageRoutes />
);
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route
path="/login"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route
path="/login/:username"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
@@ -161,7 +189,7 @@ export function App() {
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
@@ -564,6 +564,22 @@
background: #ffffff;
}
/* Ice blue has a deliberately light banner. Keep top-level navigation and
its active marker dark enough to remain readable against #9BCEF1. */
html.iceblue .light-topnav-item {
color: rgba(24, 50, 74, 0.78);
}
html.iceblue .light-topnav-item:hover,
html.iceblue .light-topnav-item--active,
html.iceblue .light-topnav-item--active:hover {
color: #18324a;
}
html.iceblue .light-topnav-item--active::after {
background: #3d6e96;
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
.light-sidebar {
position: fixed;
@@ -28,11 +28,12 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
*/
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),深蓝色=深色主题(dark)
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
{ id: "light", label: "红色", color: "#cf1322" },
{ id: "lightgreen", label: "绿色", color: "#07c05f" },
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
{ id: "iceblue", label: "冰川蓝", color: "#9BCEF1" },
{ id: "dark", label: "深蓝色", color: "#002466" },
];
@@ -12,6 +12,7 @@ import {
MoonIcon,
PanelLeftIcon,
Plus,
SnowflakeIcon,
SunIcon,
Trash2,
} from "lucide-react";
@@ -213,7 +214,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
};
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue" | "lightgreen",
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue",
) => {
if (value === globalDefault) return;
try {
@@ -225,7 +226,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
? "深色"
: value === "lightgreen"
? "绿色"
: "浅蓝";
: value === "iceblue"
? "冰川蓝"
: "浅蓝";
toast.success(`已将系统默认主题设置为${label}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
@@ -387,6 +390,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
description: t.settings.appearance.lightgreenDescription,
icon: LeafIcon,
},
{
id: "iceblue",
label: t.settings.appearance.iceblue,
description: t.settings.appearance.iceblueDescription,
icon: SnowflakeIcon,
},
],
[
t.settings.appearance.dark,
@@ -397,6 +406,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
t.settings.appearance.lightblueDescription,
t.settings.appearance.lightgreen,
t.settings.appearance.lightgreenDescription,
t.settings.appearance.iceblue,
t.settings.appearance.iceblueDescription,
],
);
@@ -414,7 +425,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen"}
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue"}
onSelect={(value) => setTheme(value)}
/>
))}
@@ -576,6 +587,16 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<LeafIcon className="mr-1.5 size-4" />
绿色
</Button>
<Button
type="button"
variant={globalDefault === "iceblue" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("iceblue")}
disabled={updateAppearance.isPending}
>
<SnowflakeIcon className="mr-1.5 size-4" />
冰川蓝
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light"
@@ -584,7 +605,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
? "深色"
: globalDefault === "lightgreen"
? "绿色"
: "浅蓝"}
: globalDefault === "iceblue"
? "冰川蓝"
: "浅蓝"}
</p>
</div>
</SettingsSection>
@@ -1101,8 +1124,8 @@ function ThemePreviewCard({
label: string;
description: string;
active: boolean;
mode: "light" | "dark" | "lightblue" | "lightgreen";
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen") => void;
mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue";
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue") => void;
}) {
const previewMode = mode;
return (
@@ -1134,8 +1157,10 @@ function ThemePreviewCard({
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
: previewMode === "lightgreen"
? "border-emerald-200 bg-white text-emerald-950"
: previewMode === "lightgreen"
? "border-emerald-200 bg-white text-emerald-950"
: previewMode === "iceblue"
? "border-sky-200 bg-[#F7FBFE] text-[#18324A]"
: "border-slate-200 bg-white text-slate-900",
)}
>
@@ -1147,8 +1172,10 @@ function ThemePreviewCard({
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: "bg-[#cf1322]",
)}
/>
+36 -5
View File
@@ -8,9 +8,10 @@ import React, {
import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
// 主题类型
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen';
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen' | 'iceblue';
// 主题配置
export interface ThemeConfig {
@@ -126,6 +127,30 @@ const themes: Record<ThemeType, ThemeConfig> = {
overlay: 'black/30',
},
},
iceblue: {
type: 'iceblue',
name: '冰川蓝主题',
colors: {
background: 'sky-50',
backgroundSecondary: 'sky-100',
text: 'slate-800',
textSecondary: 'slate-600',
// The CSS --primary token is exactly #9BCEF1. Darker blues are used
// only for readable text, hover and focus states.
primary: 'sky-200',
primaryHover: 'sky-300',
border: 'sky-200',
accent: 'sky-200',
success: 'emerald-600',
warning: 'amber-600',
error: 'red-600',
card: 'white',
cardHover: 'sky-50',
input: 'white',
inputBorder: 'sky-200',
overlay: 'slate-900/30',
},
},
};
const STORAGE_KEY = 'strategy-theme';
@@ -144,6 +169,7 @@ function readPersistedTheme(): ThemeType {
if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue';
if (saved === 'lightgreen') return 'lightgreen';
if (saved === 'iceblue') return 'iceblue';
return 'light';
}
@@ -184,7 +210,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// a theme yet, this value drives the rendered theme — and re-syncs on
// every mount so an admin change propagates without users clearing
// localStorage by hand.
const { data: appearance } = useAppearanceSettings();
const { data: appearance } = useAppearanceSettings({
enabled: !isPositionRoundtableStandaloneMode(),
});
useEffect(() => {
setMounted(true);
@@ -203,7 +231,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
current !== 'light' &&
current !== 'dark' &&
current !== 'lightblue' &&
current !== 'lightgreen'
current !== 'lightgreen' &&
current !== 'iceblue'
) {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
@@ -242,7 +271,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
? 'lightblue'
: effectiveTheme === 'lightgreen'
? 'lightgreen'
: 'light'
: effectiveTheme === 'iceblue'
? 'iceblue'
: 'light'
: readPersistedTheme();
useEffect(() => {
@@ -260,7 +291,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
root.removeAttribute('theme-mode');
root.classList.remove('dark');
if (theme === 'lightblue' || theme === 'lightgreen') {
if (theme === 'lightblue' || theme === 'lightgreen' || theme === 'iceblue') {
root.setAttribute('data-theme', theme);
} else {
root.removeAttribute('data-theme');
+9 -6
View File
@@ -150,12 +150,15 @@ export function useAllAgentsAdmin(enabled: boolean) {
return { agents: data ?? [], isLoading, error };
}
export function useAgent(id: string | null | undefined) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: !!id,
});
export function useAgent(
id: string | null | undefined,
{ enabled = true }: { enabled?: boolean } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
});
return { agent: data ?? null, isLoading, error };
}
@@ -3,12 +3,13 @@ import fallbackLogoLight from "@/assets/img/brand-logo-light.png";
import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
import { readRuntimeEnv } from "@/env";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "dark";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "dark";
const headerFallbackAssets: Record<BrandTheme, string> = {
light: fallbackLogoLight,
lightblue: fallbackLogoLightblue,
lightgreen: fallbackLogoLight,
iceblue: fallbackLogoLightblue,
dark: fallbackLogoDark,
};
@@ -16,6 +17,7 @@ const headerRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_LOGO_LIGHT_URL",
lightblue: "VITE_BRAND_LOGO_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_LOGO_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_LOGO_ICEBLUE_URL",
dark: "VITE_BRAND_LOGO_DARK_URL",
};
@@ -23,6 +25,7 @@ const welcomeRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_WELCOME_LIGHT_URL",
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
dark: "VITE_BRAND_WELCOME_DARK_URL",
};
@@ -450,11 +450,13 @@ export const enUS: Translations = {
dark: "Dark",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around WeKnora green.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
@@ -385,11 +385,13 @@ export interface Translations {
dark: string;
lightblue: string;
lightgreen: string;
iceblue: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
languageTitle: string;
languageDescription: string;
};
@@ -431,11 +431,13 @@ export const zhCN: Translations = {
dark: "深色",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
+1 -1
View File
@@ -276,7 +276,7 @@ export async function updatePublishSquares(
return data;
}
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen";
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen" | "iceblue";
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
@@ -111,10 +111,13 @@ export function useUpdatePublishSquares() {
});
}
export function useAppearanceSettings() {
export function useAppearanceSettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APPEARANCE_KEY,
queryFn: () => loadAppearanceSettings(),
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
// The provider runs even on the login page (where the user has no auth)
@@ -27,6 +27,8 @@ export default function LightAppIframeView() {
? "green"
: theme === "lightblue"
? "blue"
: theme === "iceblue"
? "iceblue"
: "red";
const src = useMemo(
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
+12 -1
View File
@@ -87,6 +87,17 @@ function getLightAccent(theme: ThemeType) {
button: "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]",
};
}
if (theme === "iceblue") {
return {
dot: "bg-[#9BCEF1]",
badge: "border-[#3D6E96]/15 bg-[#E1F1FB] text-[#2D587A]",
focus: "focus:border-[#3D6E96]/60 focus:ring-[#3D6E96]/20",
text: "text-[#2D587A]",
hoverText: "group-hover:text-[#2D587A]",
link: "text-[#2D587A] hover:text-[#1E405C]",
button: "bg-[#9BCEF1] text-[#18324A] shadow-[0_1px_3px_rgba(155,206,241,0.42)] hover:bg-[#B7DCF5]",
};
}
return {
dot: "bg-[#CF1322]",
badge: "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]",
@@ -346,7 +357,7 @@ function AppCard({
<button
type="button"
onClick={onOpen}
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:text-white dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
>
<span>进入应用</span>
<ArrowUpRight className="size-3.5" />
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const isLightGreen = theme === "lightgreen";
const [question, setQuestion] = useState("");
@@ -4,7 +4,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import { getNotebookApiBaseURL } from '@/core/config';
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen'];
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen', 'iceblue'];
interface NotebookTokenData {
doc_ids: number[];
@@ -689,6 +689,59 @@
background-color: #0056C6;
}
/* Ice blue theme — use #9BCEF1 for surfaces and #3D6E96 for actions so
text on primary controls remains readable. */
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn,
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save,
[data-theme="iceblue"] .light-app-management-page .lam-switch-on {
background-color: #9BCEF1;
}
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn,
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save {
color: #18324A;
box-shadow: 0 2px 6px rgba(155, 206, 241, 0.42);
}
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn:hover,
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save:hover {
background-color: #B7DCF5;
}
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn:active,
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save:active {
background-color: #84B9DE;
}
[data-theme="iceblue"] .light-app-management-page .lam-search-input:focus {
border-color: rgba(61, 110, 150, 0.5);
box-shadow: 0 0 0 4px rgba(155, 206, 241, 0.42);
}
[data-theme="iceblue"] .light-app-management-page .lam-view-toggle-btn.is-active,
[data-theme="iceblue"] .light-app-management-page .lam-cat-toggle-btn.is-active,
[data-theme="iceblue"] .light-app-management-page .lam-count strong,
[data-theme="iceblue"] .light-app-management-page .lam-table tr:hover .lam-row-name,
[data-theme="iceblue"] .light-app-management-page .lam-param-popover-close,
[data-theme="iceblue"] .light-app-management-page .lam-param-key,
[data-theme="iceblue"] .light-app-management-page .lam-card:hover .lam-card-title,
[data-theme="iceblue"] .light-app-management-page .lam-dialog-close:hover,
[data-theme="iceblue"] .light-app-management-page .lam-add-param-btn:hover {
color: #2D587A;
}
[data-theme="iceblue"] .light-app-management-page .lam-add-param-btn:hover,
[data-theme="iceblue"] .light-app-management-page .lam-input:focus,
[data-theme="iceblue"] .light-app-management-page .lam-dialog input[type="text"]:focus {
border-color: #5B91B9;
}
[data-theme="iceblue"] .light-app-management-page .lam-input:focus,
[data-theme="iceblue"] .light-app-management-page .lam-dialog input[type="text"]:focus,
[data-theme="iceblue"] .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
[data-theme="iceblue"] .light-app-management-page .lam-dialog textarea:focus-visible,
[data-theme="iceblue"] .light-app-management-page .t-select.t-is-focused .t-input,
[data-theme="iceblue"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
box-shadow: 0 0 0 2px rgba(155, 206, 241, 0.58);
}
[data-theme="iceblue"] .light-app-management-page .lam-dialog-icon {
background-color: #E1F1FB;
color: #2D587A;
}
/* WeKnora green theme. */
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save,
+36 -4
View File
@@ -154,26 +154,58 @@ PositionRoundtablePage
## 7. API 契约
前端基地址:`/api/position-roundtable`。消息内容和真实产物仍由既有 LangGraph/API 保存,以下接口只保存岗位会商索引与快照。
前端基地址:`/api/position-roundtable`。LangGraph 负责实时执行;SQL 同时保存岗位会商索引、结构化状态、完整对话恢复快照和产物正文副本,避免应用或 checkpoint 卷重启后页面变空。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
| `PUT` | `/sessions/{id}/intent` | 保存意图线程 ID 和结构化意图。 |
| `PUT` | `/sessions/{id}/intent` | 保存意图线程 ID、结构化意图和完整意图对话快照。 |
| `POST` | `/sessions/{id}/activate` | 冻结业务链并创建节点。 |
| `POST` | `/sessions/{id}/archive` | 归档会话,后续仅允许查看。 |
| `POST` | `/sessions/{id}/nodes/{nodeKey}/thread` | 绑定普通节点的 LangGraph 线程。 |
| `POST` | `/sessions/{id}/nodes/{nodeKey}/context` | 取得当前轮隐藏交接上下文。 |
| `POST` | `/sessions/{id}/nodes/{nodeKey}/complete-turn` | 保存回答、产物清单、上游版本并推进状态。 |
| `PUT` | `/sessions/{id}/nodes/{nodeKey}/conversation` | 保存普通岗位节点的消息与未发送草稿。 |
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
## 8. 开发与排查清单
## 8. 演示数据与免登录独立模式
完整演示数据只有一份事实源:
```text
src/position-roundtable/demo/position-roundtable-full.json
```
它包含任务、意图识别、业务链、全部岗位节点对话、岗位产物正文、方案总结和行动规划。修改数据结构时需要同步更新 `demoKey`,避免已初始化的旧 SQL 演示记录与新结构混用。
两个运行时开关用途不同:
| 配置 | 默认值 | 行为 |
| --- | --- | --- |
| `VITE_POSITION_ROUNDTABLE_DEMO_MODE` | `false` | 正常登录和正常后台请求;进入演示任务时先调用 `/demo/seed` 幂等写入数据库,失败则读取前端 JSON 只读兜底。 |
| `VITE_POSITION_ROUNDTABLE_STANDALONE_MODE` | `false` | 纯前端展台模式;跳过登录并直达岗位分析页,不加载全局后台配置,不请求模型、业务链、智能体、会话或 LangGraph 接口,只读渲染同一份 JSON。 |
部署后可直接修改 `dist/runtime-config.js`(容器部署通常挂载外部 `runtime-config.js`),无需重新构建。独立模式会绕过鉴权,只允许在隔离的演示环境开启,生产环境必须保持 `false`。
自动意图线程的时序约束:
1. 外部页面只传 `taskId` 时,先完成历史发现或创建 SQL 会话行;
2. 是否初始化智能体只看真实 `intentThreadId`,不能用 URL 是否已有 `sessionId` 代替;
3. `initIntent` 返回真实线程前,不保存“无线程欢迎语快照”;
4. 绑定线程后立即保存线程 ID,再合并数据库中的恢复消息和草稿。
这四步用于避免新建会话的恢复 effect 抢先覆盖并取消正在进行的线程初始化。
## 9. 开发与排查清单
1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
@@ -182,7 +214,7 @@ PositionRoundtablePage
5. 变更输入框视觉时:只改对应的岗位专用输入组件,不要向全局 `InputBox` 添加岗位样式。
6. 验证最少覆盖:新建会话、刷新恢复、切岗位、单节点多智能体切换、生成 Markdown、关闭后再次对话、历史会话、方案总结、行动规划。
## 9. 构建验证
## 10. 构建验证
```powershell
cd frontend-web
@@ -20,6 +20,18 @@ export type PositionNodeStatus =
| "rejected"
| "error";
/**
* SQL-owned recovery copy for a chat surface. LangGraph remains the execution
* source, but this snapshot keeps history and an unsent composer draft
* recoverable when a deployment replaces the checkpoint or sandbox volume.
*/
export interface PositionConversationSnapshot {
messages: Array<Record<string, unknown>>;
timelineEntries?: Array<Record<string, unknown>>;
inputDraft?: string;
savedAt?: string;
}
/**
* 冻结业务链中的一个席位。
*
@@ -38,6 +50,7 @@ export interface PositionRoundtableNode {
status: PositionNodeStatus;
latestAnswer: string | null;
artifactManifest: Array<Record<string, unknown>>;
conversationSnapshot: PositionConversationSnapshot | null;
revision: number;
upstreamRevision: Record<string, unknown> | null;
rejectionCount: number;
@@ -61,10 +74,13 @@ export interface PositionRoundtableSession {
chainSnapshot: Record<string, unknown> | null;
intentThreadId: string | null;
intentSnapshot: Record<string, unknown> | null;
intentConversation: PositionConversationSnapshot | null;
summaryThreadId: string | null;
summarySnapshot: Record<string, unknown> | null;
summaryConversation: PositionConversationSnapshot | null;
actionPlanThreadId: string | null;
actionPlanSnapshot: Record<string, unknown> | null;
actionPlanConversation: PositionConversationSnapshot | null;
status: "intent_pending" | "active" | "completed" | "archived";
createdAt?: string;
updatedAt?: string;
@@ -83,6 +99,7 @@ interface ServerNode {
status: PositionNodeStatus;
latest_answer: string | null;
artifact_manifest?: Array<Record<string, unknown>>;
conversation_snapshot?: PositionConversationSnapshot | null;
revision?: number;
upstream_revision?: Record<string, unknown> | null;
rejection_count?: number;
@@ -100,10 +117,13 @@ interface ServerSession {
chain_snapshot?: Record<string, unknown> | null;
intent_thread_id: string | null;
intent_snapshot?: Record<string, unknown> | null;
intent_conversation?: PositionConversationSnapshot | null;
summary_thread_id?: string | null;
summary_snapshot?: Record<string, unknown> | null;
summary_conversation?: PositionConversationSnapshot | null;
action_plan_thread_id?: string | null;
action_plan_snapshot?: Record<string, unknown> | null;
action_plan_conversation?: PositionConversationSnapshot | null;
status: PositionRoundtableSession["status"];
created_at?: string;
updated_at?: string;
@@ -120,6 +140,27 @@ async function readJson<T>(response: Response, label: string): Promise<T> {
return response.json() as Promise<T>;
}
/**
* A missing Session is a recoverable navigation state, not a generic gateway
* failure. It commonly occurs when another tab (or the history menu) deletes
* the Session that is still present in the current URL.
*/
export class PositionSessionNotFoundError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(`Position roundtable session not found: ${sessionId}`);
this.name = "PositionSessionNotFoundError";
this.sessionId = sessionId;
}
}
export function isPositionSessionNotFoundError(
error: unknown,
): error is PositionSessionNotFoundError {
return error instanceof PositionSessionNotFoundError;
}
/**
* API 使用 snake_case,页面使用 camelCase。所有字段映射集中在这里,
* 组件禁止直接依赖后端字段名,避免 API 演进时遗漏状态字段。
@@ -137,6 +178,7 @@ function toNode(node: ServerNode): PositionRoundtableNode {
status: node.status,
latestAnswer: node.latest_answer,
artifactManifest: node.artifact_manifest ?? [],
conversationSnapshot: node.conversation_snapshot ?? null,
revision: node.revision ?? 0,
upstreamRevision: node.upstream_revision ?? null,
rejectionCount: node.rejection_count ?? 0,
@@ -157,10 +199,13 @@ function toSession(session: ServerSession): PositionRoundtableSession {
chainSnapshot: session.chain_snapshot ?? null,
intentThreadId: session.intent_thread_id,
intentSnapshot: session.intent_snapshot ?? null,
intentConversation: session.intent_conversation ?? null,
summaryThreadId: session.summary_thread_id ?? null,
summarySnapshot: session.summary_snapshot ?? null,
summaryConversation: session.summary_conversation ?? null,
actionPlanThreadId: session.action_plan_thread_id ?? null,
actionPlanSnapshot: session.action_plan_snapshot ?? null,
actionPlanConversation: session.action_plan_conversation ?? null,
status: session.status,
createdAt: session.created_at,
updatedAt: session.updated_at,
@@ -199,10 +244,14 @@ export async function createPositionSession(input: {
}
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`),
"get position session",
);
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
if (response.status === 404) {
// Consume the body so the connection can be reused, then expose a typed
// error that the page can resolve by selecting another history record.
await response.text().catch(() => "");
throw new PositionSessionNotFoundError(sessionId);
}
const result = await readJson<ServerSession>(response, "get position session");
return toSession(result);
}
@@ -223,7 +272,11 @@ export async function updatePositionTaskSnapshot(
export async function savePositionIntent(
sessionId: string,
input: { intentThreadId?: string | null; intentSnapshot: object | null },
input: {
intentThreadId?: string | null;
intentSnapshot: object | null;
conversationSnapshot?: PositionConversationSnapshot | null;
},
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
@@ -232,6 +285,7 @@ export async function savePositionIntent(
body: JSON.stringify({
intent_thread_id: input.intentThreadId || null,
intent_snapshot: input.intentSnapshot,
conversation_snapshot: input.conversationSnapshot ?? null,
}),
}),
"save position intent",
@@ -265,6 +319,19 @@ export async function archivePositionSession(sessionId: string): Promise<Positio
return toSession(result);
}
export async function deletePositionSession(sessionId: string): Promise<void> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
// DELETE is deliberately idempotent from the UI's perspective. A stale
// history row that is already gone still needs to be removed locally.
if (response.status === 404) {
await response.text().catch(() => "");
return;
}
await readJson<{ deleted: boolean }>(response, "delete position session");
}
export async function bindPositionNodeThread(
sessionId: string,
nodeKey: string,
@@ -284,6 +351,81 @@ export async function bindPositionNodeThread(
return toNode(result);
}
export async function savePositionNodeConversation(
sessionId: string,
nodeKey: string,
input: {
threadId?: string | null;
conversationSnapshot: PositionConversationSnapshot;
},
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: input.threadId || null,
conversation_snapshot: input.conversationSnapshot,
}),
},
),
"save position node conversation",
);
return toNode(result);
}
export async function savePositionBuiltinConversation(
sessionId: string,
kind: "summary" | "action_plan",
input: {
threadId?: string | null;
conversationSnapshot: PositionConversationSnapshot;
},
): Promise<PositionRoundtableSession> {
const path = kind === "summary" ? "summary/state" : "action-plan/state";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: input.threadId || null,
conversation_snapshot: input.conversationSnapshot,
}),
}),
`save position ${kind} conversation`,
);
return toSession(result);
}
export interface PositionDemoSeedPayload {
demo_key: string;
session: Record<string, unknown>;
nodes: Array<Record<string, unknown>>;
}
/**
* Import the canonical frontend demo fixture into the normal SQL tables.
*
* The backend makes this operation idempotent per account and demo key. It
* returns the same Session shape as a real workflow, so no demo-only rendering
* branch is needed after a successful import.
*/
export async function seedPositionDemoSession(
payload: PositionDemoSeedPayload,
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/demo/seed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
"seed position demo session",
);
return toSession(result);
}
/**
* 在普通节点发送前取得受长度限制的隐藏交接上下文。
*
@@ -1,8 +1,16 @@
import type { ComponentProps } from "react";
import { useEffect, useRef, type ComponentProps } from "react";
import { InputBox } from "@/components/workspace/input-box";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { cn } from "@/lib/utils";
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
draftKey?: string;
draftValue?: string;
onDraftChange?: (value: string) => void;
};
/**
* 岗位会商的岗位智能体输入框。
*
@@ -11,8 +19,38 @@ import { cn } from "@/lib/utils";
*/
export function PositionAgentInput({
className,
draftKey = "",
draftValue = "",
onDraftChange,
...props
}: ComponentProps<typeof InputBox>) {
}: PositionAgentInputProps) {
const { textInput } = usePromptInputController();
const activeDraftKeyRef = useRef("");
const restoringValueRef = useRef<string | null>(null);
const textInputRef = useRef(textInput);
textInputRef.current = textInput;
useEffect(() => {
activeDraftKeyRef.current = draftKey;
if (textInputRef.current.value !== draftValue) {
restoringValueRef.current = draftValue;
textInputRef.current.setInput(draftValue);
} else {
restoringValueRef.current = null;
}
}, [draftKey, draftValue]);
useEffect(() => {
if (activeDraftKeyRef.current !== draftKey) return;
if (restoringValueRef.current !== null) {
if (textInput.value === restoringValueRef.current) {
restoringValueRef.current = null;
}
return;
}
onDraftChange?.(textInput.value);
}, [draftKey, onDraftChange, textInput.value]);
// Keep the page-specific class on the wrapper rather than adding a variant
// to InputBox. Node chat and built-in chat can evolve together while every
// other workspace keeps the global composer untouched.
@@ -25,8 +25,15 @@ import {
completePositionSummary,
preparePositionActionPlan,
preparePositionSummary,
savePositionBuiltinConversation,
type PositionRoundtableSession,
} from "@/position-roundtable/api/sessions";
import {
buildConversationSnapshot,
mergeConversationMessages,
readConversationDraft,
readConversationMessages,
} from "@/position-roundtable/lib/conversation-snapshot";
import {
initPositionActionPlanThread,
initSummaryThread,
@@ -56,6 +63,8 @@ interface PositionBuiltinAgentChatProps {
allDelivered: boolean;
summaryDone: boolean;
selectedModel: string;
/** Frontend-only demo: render the persisted snapshot without agent lookups. */
offlineDemo?: boolean;
/** Increments only for an explicit flow/dropdown selection that should begin a fresh first turn. */
autoStartRequestKey?: number;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
@@ -75,8 +84,13 @@ interface PositionBuiltinAgentChatProps {
}) => void;
}
const SUMMARY_AGENT_ID = "roundtable-summary";
const ACTION_PLAN_AGENT_ID = "position-action-planner";
/**
* 收口阶段不是业务链里的普通席位,但仍是可独立配置的智能体。
* 导出这两个固定 id,供测试分析岗右侧的「智能体配置」精确展示,避免
* 又把当前业务链席位误当成收口阶段的配置项。
*/
export const SUMMARY_AGENT_ID = "roundtable-summary";
export const ACTION_PLAN_AGENT_ID = "position-action-planner";
function builtinAgentId(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
@@ -196,6 +210,7 @@ export function PositionBuiltinAgentChat({
allDelivered,
summaryDone,
selectedModel,
offlineDemo = false,
autoStartRequestKey,
builtinAgentOptions,
onSelectBuiltinAgent,
@@ -212,10 +227,19 @@ export function PositionBuiltinAgentChat({
(kind === "summary" ? session?.summaryThreadId : session?.actionPlanThreadId) ||
recordString(snapshot, "thread_id") ||
null;
const persistedConversation =
kind === "summary" ? session?.summaryConversation : session?.actionPlanConversation;
const persistedMessages = useMemo(
() => readConversationMessages(persistedConversation),
[persistedConversation],
);
const [inputDraft, setInputDraft] = useState(() =>
readConversationDraft(persistedConversation),
);
const snapshotDone = snapshotStatus(snapshot) === "done";
const snapshotStale = snapshotStatus(snapshot) === "stale";
const [assistantId, setAssistantId] = useState(fallbackAgentId);
const { agent } = useAgent(assistantId);
const { agent } = useAgent(assistantId, { enabled: !offlineDemo });
const agentDisplayName = desensitize(agent?.name || builtinAgentFallbackName(kind));
const [boundThreadId, setBoundThreadId] = useState<string | null>(persistedThreadId);
const boundThreadIdRef = useRef<string | null>(persistedThreadId);
@@ -234,6 +258,7 @@ export function PositionBuiltinAgentChat({
setAssistantId(fallbackAgentId);
setBoundThreadId(persistedThreadId);
boundThreadIdRef.current = persistedThreadId;
setInputDraft(readConversationDraft(persistedConversation));
draftThreadIdRef.current = uuid();
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
@@ -327,11 +352,89 @@ export function PositionBuiltinAgentChat({
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const conversationThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
const displayThreadId = boundThreadId ?? `position-builtin-${kind}-${session?.id ?? "draft"}`;
const threadContextValue = useMemo(() => ({ thread }), [thread]);
const recoveredMessages = useMemo(
() => mergeConversationMessages(persistedMessages, thread.messages),
[persistedMessages, thread.messages],
);
const displayThread = useMemo(
() =>
({
...thread,
messages: recoveredMessages,
}) as BaseStream<AgentThreadState>,
[recoveredMessages, thread],
);
const threadContextValue = useMemo(() => ({ thread: displayThread }), [displayThread]);
const isSubmitting = pendingSubmission !== null;
const status = thread.error ? "error" : thread.isLoading || isSubmitting ? "streaming" : "ready";
const persistedArtifacts = useMemo(() => readBuiltinArtifacts(snapshot), [snapshot]);
const pendingConversationRef = useRef<{
signature: string;
threadId: string | null;
snapshot: ReturnType<typeof buildConversationSnapshot>;
} | null>(null);
const lastPersistedConversationRef = useRef("");
const conversationSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flushConversationSnapshot = useCallback(() => {
const currentSessionId = session?.id;
const pending = pendingConversationRef.current;
if (!currentSessionId || !pending) return;
pendingConversationRef.current = null;
if (conversationSaveTimerRef.current) {
clearTimeout(conversationSaveTimerRef.current);
conversationSaveTimerRef.current = null;
}
lastPersistedConversationRef.current = pending.signature;
void savePositionBuiltinConversation(currentSessionId, kind, {
threadId: pending.threadId,
conversationSnapshot: pending.snapshot,
})
.then(onSessionUpdate)
.catch((error) => {
lastPersistedConversationRef.current = "";
console.warn(`Failed to persist position ${kind} conversation snapshot`, error);
});
}, [kind, onSessionUpdate, session?.id]);
useEffect(() => {
if (!session || session.status === "archived") return;
const conversationSnapshot = buildConversationSnapshot(recoveredMessages, inputDraft);
const signature = JSON.stringify([
conversationThreadId,
conversationSnapshot.messages,
conversationSnapshot.inputDraft ?? "",
]);
if (signature === lastPersistedConversationRef.current) return;
pendingConversationRef.current = {
signature,
threadId: conversationThreadId,
snapshot: conversationSnapshot,
};
if (conversationSaveTimerRef.current) clearTimeout(conversationSaveTimerRef.current);
conversationSaveTimerRef.current = setTimeout(flushConversationSnapshot, 650);
return () => {
if (conversationSaveTimerRef.current) {
clearTimeout(conversationSaveTimerRef.current);
conversationSaveTimerRef.current = null;
}
};
}, [
conversationThreadId,
flushConversationSnapshot,
inputDraft,
recoveredMessages,
session,
]);
useEffect(
() => () => {
flushConversationSnapshot();
},
[flushConversationSnapshot],
);
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
@@ -570,11 +673,11 @@ export function PositionBuiltinAgentChat({
</section>
)}
{boundThreadId ? (
{boundThreadId || recoveredMessages.length > 0 ? (
<MessageList
className="min-h-0 flex-1"
threadId={boundThreadId}
thread={thread}
threadId={boundThreadId ?? displayThreadId}
thread={displayThread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Use the built-in agent's regular submit path. This keeps a
@@ -622,6 +725,9 @@ export function PositionBuiltinAgentChat({
status={status}
context={settings.context}
disabled={!canWrite || isSubmitting}
draftKey={`${session?.id ?? "draft"}:${kind}`}
draftValue={inputDraft}
onDraftChange={setInputDraft}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
onSubmit={(message) => void handleSubmit(message)}
@@ -23,6 +23,7 @@ import {
bindPositionNodeThread,
completePositionNodeTurn,
getPositionNodeContext,
savePositionNodeConversation,
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
@@ -33,6 +34,12 @@ import {
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput";
import {
buildConversationSnapshot,
mergeConversationMessages,
readConversationDraft,
readConversationMessages,
} from "@/position-roundtable/lib/conversation-snapshot";
interface PositionNodeSwitchOption {
node: PositionRoundtableNode;
@@ -47,6 +54,8 @@ interface PositionNodeChatProps {
node: PositionRoundtableNode;
currentPositionId: string;
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
/** Frontend-only demo: render the SQL snapshot without resolving agent APIs. */
offlineDemo?: boolean;
agentName?: string | null;
positionNodeOptions?: PositionNodeSwitchOption[];
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
@@ -217,8 +226,9 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
* Direct single-agent chat for one frozen business-chain node.
*
* It deliberately uses the regular `useThreadStream` + `MessageList` +
* `PositionAgentInput` stack. The position Session merely records which existing thread
* belongs to the node; it is not a second message persistence mechanism.
* `PositionAgentInput` stack. LangGraph remains the live-chat source of truth, while
* the position session also keeps a JSON recovery snapshot so a replaced checkpoint
* volume does not turn an existing history into a blank page.
*/
export function PositionNodeChat({
sessionId,
@@ -227,6 +237,7 @@ export function PositionNodeChat({
node,
currentPositionId,
sessionStatus,
offlineDemo = false,
agentName,
positionNodeOptions,
onSelectPositionNode,
@@ -240,7 +251,7 @@ export function PositionNodeChat({
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
const [rejectionReasonOpen, setRejectionReasonOpen] = useState(false);
const { agent } = useAgent(node.agentId);
const { agent } = useAgent(node.agentId, { enabled: !offlineDemo });
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
const lastRejection = node.status === "rejected" ? node.lastRejection : null;
const rejectionReason =
@@ -261,6 +272,13 @@ export function PositionNodeChat({
const autoPreviewRunArmedRef = useRef(false);
const editMessage = useEditMessage();
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
const persistedMessages = useMemo(
() => readConversationMessages(node.conversationSnapshot),
[node.conversationSnapshot],
);
const [inputDraft, setInputDraft] = useState(() =>
readConversationDraft(node.conversationSnapshot),
);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite =
sessionStatus !== "archived" &&
@@ -272,9 +290,13 @@ export function PositionNodeChat({
useEffect(() => {
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
setInputDraft(readConversationDraft(node.conversationSnapshot));
setMessageContentOverrides({});
autoPreviewBaselineCallKeysRef.current = new Set();
autoPreviewRunArmedRef.current = false;
// The component is keyed by nodeKey in the page. This reset is therefore
// only a defensive guard for callers that reuse the component instance.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.nodeKey, node.threadId]);
const bindCreatedThread = useCallback(
@@ -356,6 +378,82 @@ export function PositionNodeChat({
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const persistedThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
const recoveredMessages = useMemo(
() => mergeConversationMessages(persistedMessages, thread.messages),
[persistedMessages, thread.messages],
);
const displayThread = useMemo(
() =>
({
...thread,
messages: recoveredMessages,
}) as BaseStream<AgentThreadState>,
[recoveredMessages, thread],
);
const pendingConversationRef = useRef<{
signature: string;
threadId: string | null;
snapshot: ReturnType<typeof buildConversationSnapshot>;
} | null>(null);
const lastPersistedConversationRef = useRef("");
const conversationSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flushConversationSnapshot = useCallback(() => {
const pending = pendingConversationRef.current;
if (!pending) return;
pendingConversationRef.current = null;
if (conversationSaveTimerRef.current) {
clearTimeout(conversationSaveTimerRef.current);
conversationSaveTimerRef.current = null;
}
lastPersistedConversationRef.current = pending.signature;
void savePositionNodeConversation(sessionId, node.nodeKey, {
threadId: pending.threadId,
conversationSnapshot: pending.snapshot,
})
.then(onNodeUpdate)
.catch((error) => {
// Draft autosave is deliberately quiet during typing, but leave the
// signature retryable so the next edit/stream delta writes it again.
lastPersistedConversationRef.current = "";
console.warn("Failed to persist position node conversation snapshot", error);
});
}, [node.nodeKey, onNodeUpdate, sessionId]);
useEffect(() => {
if (sessionStatus === "archived") return;
const snapshot = buildConversationSnapshot(recoveredMessages, inputDraft);
const signature = JSON.stringify([
persistedThreadId,
snapshot.messages,
snapshot.inputDraft ?? "",
]);
if (signature === lastPersistedConversationRef.current) return;
pendingConversationRef.current = { signature, threadId: persistedThreadId, snapshot };
if (conversationSaveTimerRef.current) clearTimeout(conversationSaveTimerRef.current);
conversationSaveTimerRef.current = setTimeout(flushConversationSnapshot, 650);
return () => {
if (conversationSaveTimerRef.current) {
clearTimeout(conversationSaveTimerRef.current);
conversationSaveTimerRef.current = null;
}
};
}, [
flushConversationSnapshot,
inputDraft,
persistedThreadId,
recoveredMessages,
sessionStatus,
]);
useEffect(
() => () => {
// A quick position switch must not discard the last <650 ms of typing.
flushConversationSnapshot();
},
[flushConversationSnapshot],
);
const latestAiMessageId = useMemo(() => {
for (let index = thread.messages.length - 1; index >= 0; index -= 1) {
const message = thread.messages[index];
@@ -556,7 +654,7 @@ export function PositionNodeChat({
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
const threadContextValue = useMemo(() => ({ thread }), [thread]);
const threadContextValue = useMemo(() => ({ thread: displayThread }), [displayThread]);
const readOnlyHint =
sessionStatus === "archived"
? "该会话已归档,仅可查看历史对话和产物"
@@ -678,11 +776,11 @@ export function PositionNodeChat({
</section>
)}
{boundThreadId ? (
{boundThreadId || recoveredMessages.length > 0 ? (
<MessageList
className="min-h-0 flex-1"
threadId={boundThreadId}
thread={thread}
threadId={boundThreadId ?? displayThreadId}
thread={displayThread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Clarification cards are rendered by MessageList, but position
@@ -722,6 +820,9 @@ export function PositionNodeChat({
status={status}
context={settings.context}
disabled={!canWrite}
draftKey={`${sessionId}:${node.nodeKey}`}
draftValue={inputDraft}
onDraftChange={setInputDraft}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
onSubmit={(message) => void handleSubmit(message)}
@@ -0,0 +1,34 @@
import { readRuntimeFlag } from "@/env";
/**
* Seeds the canonical demo into SQL when the gateway is available and falls
* back to the same JSON snapshot when it is not.
*/
export const POSITION_ROUNDTABLE_DEMO_FLAG =
"VITE_POSITION_ROUNDTABLE_DEMO_MODE";
/**
* Kiosk/standalone switch. This is deliberately separate from demo seeding:
* when enabled the app bypasses login, renders only the position-roundtable
* workspace and must not issue any backend request.
*/
export const POSITION_ROUNDTABLE_STANDALONE_FLAG =
"VITE_POSITION_ROUNDTABLE_STANDALONE_MODE";
export const POSITION_ROUNDTABLE_ROUTE =
"/page/strategy/qa/position-roundtable";
export function isPositionRoundtableStandaloneMode(): boolean {
return readRuntimeFlag(POSITION_ROUNDTABLE_STANDALONE_FLAG);
}
/**
* Standalone mode always consumes the full demo fixture, so operators only
* need to enable the standalone flag (the SQL-seeding flag may remain false).
*/
export function isPositionRoundtableDemoEnabled(): boolean {
return (
isPositionRoundtableStandaloneMode() ||
readRuntimeFlag(POSITION_ROUNDTABLE_DEMO_FLAG)
);
}
@@ -0,0 +1,125 @@
import {
seedPositionDemoSession,
type PositionDemoSeedPayload,
type PositionRoundtableNode,
type PositionRoundtableSession,
} from "@/position-roundtable/api/sessions";
import type { PositionTask } from "@/position-roundtable/lib/mock-task-source";
import {
isPositionRoundtableDemoEnabled,
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_DEMO_FLAG,
POSITION_ROUNDTABLE_ROUTE,
POSITION_ROUNDTABLE_STANDALONE_FLAG,
} from "./position-roundtable-demo-config";
import fixture from "./position-roundtable-full.json";
export const POSITION_ROUNDTABLE_DEMO_SESSION_PREFIX = "local-demo-position-roundtable-";
export {
isPositionRoundtableDemoEnabled,
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_DEMO_FLAG,
POSITION_ROUNDTABLE_ROUTE,
POSITION_ROUNDTABLE_STANDALONE_FLAG,
};
type DemoFixture = {
demoKey: string;
session: PositionRoundtableSession;
};
const canonicalFixture = fixture as unknown as DemoFixture;
let initialization: Promise<PositionRoundtableSession> | null = null;
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function nodeSeed(node: PositionRoundtableNode): Record<string, unknown> {
return {
node_key: node.nodeKey,
stage_index: node.stageIndex,
seat_index: node.seatIndex,
agent_id: node.agentId,
position_id: node.positionId,
thread_id: node.threadId,
status: node.status,
latest_answer: node.latestAnswer,
artifact_manifest: node.artifactManifest,
conversation_snapshot: node.conversationSnapshot,
revision: node.revision,
upstream_revision: node.upstreamRevision,
rejection_count: node.rejectionCount,
last_rejection: node.lastRejection,
invalidated_by: node.invalidatedBy,
};
}
function seedPayload(session: PositionRoundtableSession): PositionDemoSeedPayload {
return {
demo_key: canonicalFixture.demoKey,
session: {
external_task_id: session.externalTaskId,
task_snapshot: session.taskSnapshot,
chain_id: session.chainId,
chain_snapshot: session.chainSnapshot,
intent_thread_id: session.intentThreadId,
intent_snapshot: session.intentSnapshot,
intent_conversation: session.intentConversation,
summary_thread_id: session.summaryThreadId,
summary_snapshot: session.summarySnapshot,
summary_conversation: session.summaryConversation,
action_plan_thread_id: session.actionPlanThreadId,
action_plan_snapshot: session.actionPlanSnapshot,
action_plan_conversation: session.actionPlanConversation,
status: session.status,
},
nodes: (session.nodes ?? []).map(nodeSeed),
};
}
export function isLocalPositionDemoSession(sessionId: string | null | undefined): boolean {
return Boolean(sessionId?.startsWith(POSITION_ROUNDTABLE_DEMO_SESSION_PREFIX));
}
/** Return an isolated copy so page edits never mutate the imported JSON module. */
export function getLocalPositionDemoSession(): PositionRoundtableSession {
return clone(canonicalFixture.session);
}
export function getPositionDemoTask(): PositionTask {
const task = canonicalFixture.session.taskSnapshot;
return {
id: String(task.id ?? canonicalFixture.session.externalTaskId ?? "demo-position-task"),
title: String(task.title ?? "岗位会商演示任务"),
description: String(task.description ?? ""),
direction: String(task.direction ?? ""),
source: String(task.source ?? "演示数据"),
issuedAt: String(task.issuedAt ?? ""),
};
}
/**
* Seed SQL when possible; otherwise return the exact same fixture locally.
*
* A module-level promise prevents StrictMode and multiple page effects from
* creating parallel requests. Failures are deliberately non-fatal because
* the demo must remain viewable with the gateway, database or network offline.
*/
export function initializePositionRoundtableDemo(): Promise<PositionRoundtableSession> {
if (isPositionRoundtableStandaloneMode()) {
return Promise.resolve(getLocalPositionDemoSession());
}
if (!initialization) {
const localSession = getLocalPositionDemoSession();
initialization = seedPositionDemoSession(seedPayload(localSession)).catch((error) => {
console.warn(
"Position roundtable demo backend seed failed; using local JSON fallback",
error,
);
return localSession;
});
}
return initialization;
}
@@ -0,0 +1,462 @@
{
"demoKey": "summer-camp-full-workflow-v1",
"session": {
"id": "local-demo-position-roundtable-full-v1",
"externalTaskId": "demo-position-task-summer-camp-v1",
"taskSnapshot": {
"id": "demo-position-task-summer-camp-v1",
"title": "暑期游泳夏令营宣传",
"description": "围绕暑期游泳夏令营开展招生宣传,建立家长信任并提升有效报名转化。",
"direction": "暑期活动",
"source": "演示数据",
"issuedAt": "2026-07-23 09:00:00"
},
"chainId": "demo-summer-camp-chain-v1",
"chainSnapshot": {
"id": "demo-summer-camp-chain-v1",
"title": "暑期活动全流程分析链",
"description": "从意图识别、目的分析、行为分析、预期评估到组织协同的完整岗位会商流程。",
"seats": [
{
"agent_id": "demo-intelligence-agent",
"name": "情报分析智能体",
"description": "识别任务目标、约束与关键假设",
"position_id": "intelligence"
},
{
"agent_id": "demo-md-analysis-agent",
"name": "MD分析智能体",
"description": "分析宣传目的并筛选核心目标",
"position_id": "planning"
},
{
"agent_id": "demo-xwt-analysis-agent",
"name": "XWT分析智能体",
"description": "分析关键行为体与协同关系",
"position_id": "solution"
},
{
"agent_id": "demo-behavior-analysis-agent",
"name": "预期行为与QDY分析智能体",
"description": "推演预期行为、驱动因素与风险",
"position_id": "evaluation"
},
{
"agent_id": "demo-xs-analysis-agent",
"name": "XS分析智能体",
"description": "形成组织协同与执行保障建议",
"position_id": "planning"
}
],
"stages": [
["demo-intelligence-agent"],
["demo-md-analysis-agent"],
["demo-xwt-analysis-agent"],
["demo-behavior-analysis-agent"],
["demo-xs-analysis-agent"]
],
"stageGoals": [
"确认任务意图、关键约束与假设",
"筛选宣传任务的核心目的",
"识别关键行为体及协同关系",
"推演预期行为、驱动因素和主要风险",
"形成组织协同、资源配置和执行保障建议"
],
"coordinatorPrompt": "按阶段顺序承接上游有效交付,每个岗位必须输出完整 Markdown 报告。",
"seatMode": "pro",
"gatherFirst": false
},
"intentThreadId": null,
"intentSnapshot": {
"objective": "围绕暑期游泳夏令营开展持续宣传推广,建立家长信任并提升有效报名转化。",
"constraints": [
"总预算不少于 15 万元",
"宣传周期不少于 2 个月",
"重点覆盖青少年及其家长",
"宣传方案需在暑期开始前落地"
],
"assumptions": [
"夏令营已有明确课程内容、师资与场地",
"可组合使用线上内容投放、私域运营和线下活动",
"报名承接页面和咨询团队能够配合宣传节奏"
]
},
"intentConversation": {
"messages": [
{
"id": 1,
"sender": "agent",
"senderName": "RW理解智能体",
"content": "我已读取任务信息。为了形成可执行的宣传方案,请补充预算、宣传周期和目标受众。",
"time": "09:02"
},
{
"id": 2,
"sender": "user",
"senderName": "用户",
"content": "预算不少于15万元,宣传周期两个月以上,主要面向青少年及其家长。",
"time": "09:04"
},
{
"id": 3,
"sender": "agent",
"senderName": "RW理解智能体",
"content": "信息已经充分。我会把报名转化与家长信任建设作为核心目标,并将周期、预算和落地时间作为硬约束。",
"time": "09:05"
},
{
"id": 4,
"sender": "agent",
"senderName": "RW理解智能体",
"content": "✅ 任务意图已明确\n\n核心目标:围绕暑期游泳夏令营开展持续宣传推广,建立家长信任并提升有效报名转化。\n\n关键约束:预算不少于15万元;宣传周期不少于2个月;重点覆盖青少年及其家长;暑期开始前落地。",
"time": "09:06"
}
],
"timelineEntries": [
{
"id": 1,
"time": "09:02",
"title": "读取任务",
"desc": "已载入暑期游泳夏令营宣传任务"
},
{
"id": 2,
"time": "09:04",
"title": "补齐约束",
"desc": "确认预算、周期与目标受众"
},
{
"id": 3,
"time": "09:06",
"title": "意图确认",
"desc": "形成可执行的结构化任务意图"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T09:06:00+08:00"
},
"summaryThreadId": null,
"summarySnapshot": {
"status": "done",
"title": "暑期游泳夏令营岗位会商方案总结报告.md",
"content": "方案围绕“报名转化、品牌信任、私域沉淀”三项目标,采用预热、爆发、冲刺、沉淀四阶段推进,并建立跨岗位协同和风险校正机制。",
"artifact_manifest": [
{
"name": "暑期游泳夏令营岗位会商方案总结报告.md",
"path": "mnt/user-data/outputs/暑期游泳夏令营岗位会商方案总结报告.md",
"mime_type": "text/markdown",
"content": "# 暑期游泳夏令营岗位会商方案总结报告\n\n## 一、方案结论\n\n本方案以**报名转化**为主目标,以**家长信任建设**为关键支撑,以**私域用户沉淀**为长期资产,形成“认知—信任—咨询—报名—复购”的完整链路。\n\n## 二、核心策略\n\n1. **预热期(第1—2周)**:完成品牌内容、师资安全背书、报名页面和咨询话术准备。\n2. **蓄水期(第3—4周)**:通过短视频、公众号、家长社群和异业合作积累意向线索。\n3. **爆发期(第5—6周)**:集中投放高转化素材,配合公开课、场馆体验日推动咨询转报名。\n4. **冲刺沉淀期(第7—8周)**:针对未转化线索分层跟进,同时沉淀家长私域资产。\n\n## 三、资源建议\n\n- 媒体与内容投放:40%\n- 线下体验及异业合作:25%\n- 内容制作与品牌背书:20%\n- 私域运营及咨询承接:10%\n- 风险与机动预算:5%\n\n## 四、关键风险\n\n- 安全信息表达不充分会直接影响家长决策。\n- 投放与咨询承接不同步会造成线索浪费。\n- 暑期档期变化需要保留可调整的活动与预算窗口。\n\n## 五、建议指标\n\n关注曝光到咨询率、咨询到体验率、体验到报名率、单个有效报名成本、家长私域留存率五项核心指标。"
}
],
"primary_artifact": {
"name": "暑期游泳夏令营岗位会商方案总结报告.md",
"path": "mnt/user-data/outputs/暑期游泳夏令营岗位会商方案总结报告.md",
"mime_type": "text/markdown",
"content": "# 暑期游泳夏令营岗位会商方案总结报告\n\n## 一、方案结论\n\n本方案以**报名转化**为主目标,以**家长信任建设**为关键支撑,以**私域用户沉淀**为长期资产,形成“认知—信任—咨询—报名—复购”的完整链路。\n\n## 二、核心策略\n\n1. 预热期:完成品牌内容、安全背书与承接准备。\n2. 蓄水期:多渠道积累意向线索。\n3. 爆发期:集中投放并推动体验转报名。\n4. 冲刺沉淀期:分层跟进并沉淀私域资产。\n\n## 三、风险提示\n\n安全表达、咨询承接和暑期档期是需要持续监测的三项风险。"
},
"source_revisions": {
"stage-1-seat-1-demo-intelligence-agent": 1,
"stage-2-seat-1-demo-md-analysis-agent": 1,
"stage-3-seat-1-demo-xwt-analysis-agent": 1,
"stage-4-seat-1-demo-behavior-analysis-agent": 1,
"stage-5-seat-1-demo-xs-analysis-agent": 1
},
"generated_at": "2026-07-23T10:20:00+08:00"
},
"summaryConversation": {
"messages": [
{
"id": "demo-summary-human-1",
"type": "human",
"content": "请基于当前所有岗位交付,开始生成方案总结。"
},
{
"id": "demo-summary-ai-1",
"type": "ai",
"content": "已汇总全部岗位材料。最终方案以报名转化为主线、家长信任为支撑、私域沉淀为长期资产,并按照预热、蓄水、爆发、冲刺沉淀四阶段执行。\n\n完整 Markdown 报告已经交付。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T10:20:00+08:00"
},
"actionPlanThreadId": null,
"actionPlanSnapshot": {
"status": "done",
"title": "暑期游泳夏令营行动规划.md",
"content": "行动规划按8周拆解任务、责任岗位、交付物和验收指标,确保内容、投放、体验活动、咨询承接与复盘同步推进。",
"artifact_manifest": [
{
"name": "暑期游泳夏令营行动规划.md",
"path": "mnt/user-data/outputs/暑期游泳夏令营行动规划.md",
"mime_type": "text/markdown",
"content": "# 暑期游泳夏令营行动规划\n\n## 第1—2周:准备与预热\n\n- 明确招生目标、班型、名额和价格策略。\n- 完成安全保障、师资介绍、课程亮点三类核心素材。\n- 上线报名页面、咨询SOP和数据看板。\n\n## 第3—4周:线索蓄水\n\n- 启动短视频、公众号、社群和本地生活平台内容投放。\n- 联合学校周边商户、亲子机构和社区开展异业引流。\n- 每周复盘渠道线索质量,淘汰低效素材。\n\n## 第5—6周:转化爆发\n\n- 举办场馆开放日和体验课。\n- 对高意向家庭安排一对一咨询与限时权益。\n- 保证线索在30分钟内首次响应、24小时内二次跟进。\n\n## 第7—8周:冲刺与沉淀\n\n- 对未报名家庭按顾虑类型进行分层触达。\n- 完成报名用户入群、行前说明和安全告知。\n- 输出渠道ROI、转化漏斗和下一周期优化清单。\n\n## 责任分工\n\n| 任务 | 责任角色 | 验收标准 |\n|---|---|---|\n| 内容与品牌背书 | 内容运营 | 三类核心素材按期上线 |\n| 媒体投放 | 增长运营 | 每周完成渠道ROI复盘 |\n| 体验活动 | 活动执行 | 到场率和报名转化达标 |\n| 咨询承接 | 招生顾问 | 首次响应不超过30分钟 |\n| 数据复盘 | 项目负责人 | 每周输出行动校正清单 |"
}
],
"primary_artifact": {
"name": "暑期游泳夏令营行动规划.md",
"path": "mnt/user-data/outputs/暑期游泳夏令营行动规划.md",
"mime_type": "text/markdown",
"content": "# 暑期游泳夏令营行动规划\n\n## 8周执行节奏\n\n- 第1—2周:准备品牌素材、报名承接和安全背书。\n- 第3—4周:多渠道蓄水并持续优化线索质量。\n- 第5—6周:体验活动与高意向咨询集中转化。\n- 第7—8周:冲刺报名、用户入群和数据复盘。\n\n每项任务均配置责任角色、时间节点和验收标准。"
},
"source_revisions": {
"stage-1-seat-1-demo-intelligence-agent": 1,
"stage-2-seat-1-demo-md-analysis-agent": 1,
"stage-3-seat-1-demo-xwt-analysis-agent": 1,
"stage-4-seat-1-demo-behavior-analysis-agent": 1,
"stage-5-seat-1-demo-xs-analysis-agent": 1
},
"generated_at": "2026-07-23T10:35:00+08:00"
},
"actionPlanConversation": {
"messages": [
{
"id": "demo-action-human-1",
"type": "human",
"content": "请基于当前方案总结,生成可执行的行动规划。"
},
{
"id": "demo-action-ai-1",
"type": "ai",
"content": "行动规划已按8周拆解为准备预热、线索蓄水、转化爆发、冲刺沉淀四个阶段,并补充责任角色、验收标准和风险校正节点。\n\n完整 Markdown 行动规划已经交付。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T10:35:00+08:00"
},
"status": "completed",
"createdAt": "2026-07-23T09:00:00+08:00",
"updatedAt": "2026-07-23T10:35:00+08:00",
"nodes": [
{
"id": "local-demo-node-intelligence",
"sessionId": "local-demo-position-roundtable-full-v1",
"nodeKey": "stage-1-seat-1-demo-intelligence-agent",
"stageIndex": 0,
"seatIndex": 0,
"agentId": "demo-intelligence-agent",
"positionId": "intelligence",
"threadId": null,
"status": "done",
"latestAnswer": "情报分析岗已完成任务意图识别,确认核心目标、预算周期约束和关键假设。",
"artifactManifest": [],
"conversationSnapshot": {
"messages": [
{
"id": "demo-intelligence-human-1",
"type": "human",
"content": "请确认当前任务意图和关键约束。"
},
{
"id": "demo-intelligence-ai-1",
"type": "ai",
"content": "任务意图已经确认:围绕暑期游泳夏令营开展持续宣传推广,核心是建立家长信任并提升有效报名转化。预算不少于15万元,周期不少于2个月。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T09:06:00+08:00"
},
"revision": 1,
"upstreamRevision": {},
"rejectionCount": 0,
"lastRejection": null,
"invalidatedBy": null,
"createdAt": "2026-07-23T09:06:00+08:00",
"updatedAt": "2026-07-23T09:06:00+08:00"
},
{
"id": "local-demo-node-md",
"sessionId": "local-demo-position-roundtable-full-v1",
"nodeKey": "stage-2-seat-1-demo-md-analysis-agent",
"stageIndex": 1,
"seatIndex": 0,
"agentId": "demo-md-analysis-agent",
"positionId": "planning",
"threadId": null,
"status": "done",
"latestAnswer": "目的分析已完成,筛选出报名转化、品牌信任建设和私域用户沉淀三项核心目的。",
"artifactManifest": [
{
"name": "目的分析报告-暑期游泳夏令营宣传.md",
"path": "mnt/user-data/outputs/目的分析报告-暑期游泳夏令营宣传.md",
"mime_type": "text/markdown",
"size_bytes": 2468,
"modified_at": "2026-07-23T09:25:00+08:00",
"content": "# 目的分析报告:暑期游泳夏令营宣传\n\n## Top 1:报名转化\n\n通过精准触达、体验活动和高效咨询承接,最大化有效报名人数。核心指标包括有效线索数、咨询率、体验到场率和报名转化率。\n\n## Top 2:品牌信任建设\n\n围绕安全保障、专业师资、课程设计和场馆资质建立家长信任,降低家长在安全性与课程价值上的决策顾虑。\n\n## Top 3:私域用户沉淀\n\n将咨询家庭沉淀到家长社群和企业微信,形成可持续触达的用户资产,为后续续班、转介绍和其他课程推广提供基础。\n\n## 目的协同关系\n\n三项目的形成“转化主线 + 信任支撑 + 私域副线”:信任建设提升报名转化,报名过程同步沉淀私域,私域运营进一步促进转介绍。"
}
],
"conversationSnapshot": {
"messages": [
{
"id": "demo-md-human-1",
"type": "human",
"content": "请基于已确认意图分析宣传任务的核心目的,并形成正式报告。"
},
{
"id": "demo-md-ai-1",
"type": "ai",
"content": "目的分析已完成。我从候选目的中筛选出报名转化、品牌信任建设和私域用户沉淀三项核心目的,并说明了三者的协同关系。完整 Markdown 报告已交付。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T09:25:00+08:00"
},
"revision": 1,
"upstreamRevision": {
"stage-1-seat-1-demo-intelligence-agent": 1
},
"rejectionCount": 0,
"lastRejection": null,
"invalidatedBy": null,
"createdAt": "2026-07-23T09:10:00+08:00",
"updatedAt": "2026-07-23T09:25:00+08:00"
},
{
"id": "local-demo-node-xwt",
"sessionId": "local-demo-position-roundtable-full-v1",
"nodeKey": "stage-3-seat-1-demo-xwt-analysis-agent",
"stageIndex": 2,
"seatIndex": 0,
"agentId": "demo-xwt-analysis-agent",
"positionId": "solution",
"threadId": null,
"status": "done",
"latestAnswer": "行为体分析已完成,明确家长、青少年、招生顾问、教练团队和合作渠道的角色及协同关系。",
"artifactManifest": [
{
"name": "行为体分析报告-暑期游泳夏令营宣传.md",
"path": "mnt/user-data/outputs/行为体分析报告-暑期游泳夏令营宣传.md",
"mime_type": "text/markdown",
"size_bytes": 2812,
"modified_at": "2026-07-23T09:42:00+08:00",
"content": "# 行为体分析报告:暑期游泳夏令营宣传\n\n## 核心行为体\n\n| 行为体 | 角色 | 关键关注 |\n|---|---|---|\n| 家长 | 决策者与付款者 | 安全、师资、课程价值、时间安排 |\n| 青少年学员 | 使用者与影响者 | 趣味性、同伴体验、成长获得感 |\n| 招生顾问 | 转化承接者 | 线索质量、响应效率、标准话术 |\n| 教练团队 | 专业与安全背书 | 教学质量、安全规范、服务体验 |\n| 合作渠道 | 触达与增信伙伴 | 用户匹配、合作收益、品牌风险 |\n\n## 关键关系\n\n- 家长通过教练团队和场馆资质判断安全与专业程度。\n- 青少年的体验反馈显著影响家长最终决策。\n- 招生顾问需要承接投放线索,并把高频顾虑反馈给内容团队。\n- 合作渠道负责扩大本地触达,同时为活动提供第三方信任背书。\n\n## 协同建议\n\n建立“内容投放—咨询承接—体验活动—报名转化—私域沉淀”的闭环,每周同步线索质量、家长顾虑和转化数据。"
}
],
"conversationSnapshot": {
"messages": [
{
"id": "demo-xwt-human-1",
"type": "human",
"content": "请识别宣传任务中的关键行为体、角色关系与协同机制。"
},
{
"id": "demo-xwt-ai-1",
"type": "ai",
"content": "行为体分析已完成。家长是核心决策者,青少年是体验影响者,招生顾问和教练团队负责转化与专业背书,合作渠道扩大本地触达。完整 Markdown 报告已交付。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T09:42:00+08:00"
},
"revision": 1,
"upstreamRevision": {
"stage-1-seat-1-demo-intelligence-agent": 1,
"stage-2-seat-1-demo-md-analysis-agent": 1
},
"rejectionCount": 0,
"lastRejection": null,
"invalidatedBy": null,
"createdAt": "2026-07-23T09:26:00+08:00",
"updatedAt": "2026-07-23T09:42:00+08:00"
},
{
"id": "local-demo-node-behavior",
"sessionId": "local-demo-position-roundtable-full-v1",
"nodeKey": "stage-4-seat-1-demo-behavior-analysis-agent",
"stageIndex": 3,
"seatIndex": 0,
"agentId": "demo-behavior-analysis-agent",
"positionId": "evaluation",
"threadId": null,
"status": "done",
"latestAnswer": "预期行为分析已完成,形成家长咨询、学员体验、顾问跟进和渠道协同的行为预期及风险校正建议。",
"artifactManifest": [
{
"name": "预期行为与驱动因素分析报告-暑期游泳夏令营宣传.md",
"path": "mnt/user-data/outputs/预期行为与驱动因素分析报告-暑期游泳夏令营宣传.md",
"mime_type": "text/markdown",
"size_bytes": 3016,
"modified_at": "2026-07-23T09:58:00+08:00",
"content": "# 预期行为与驱动因素分析报告\n\n## 家长预期行为\n\n家长将先浏览安全、师资和课程内容,再进行价格与时间比较;高意向家庭通常会咨询细节、参加体验活动并在获得安全承诺后完成报名。\n\n**主要驱动因素**:孩子兴趣、场馆安全、教练资质、同龄口碑、时间便利性。\n\n## 学员预期行为\n\n青少年更容易被趣味课程、同伴互动、进阶成果和可视化荣誉吸引,体验活动后的正向反馈会显著影响家长决策。\n\n## 招生顾问预期行为\n\n顾问需要快速响应、识别顾虑、推荐合适班型,并在体验后24小时内完成重点跟进。\n\n## 风险与校正\n\n- 安全信息不清:增加安全流程视频、保险与资质说明。\n- 咨询响应慢:设置30分钟首响和未接通回访机制。\n- 体验到场低:活动前24小时二次提醒并提供路线指引。\n- 投放线索质量低:每周按渠道计算有效线索成本并动态调配预算。"
}
],
"conversationSnapshot": {
"messages": [
{
"id": "demo-behavior-human-1",
"type": "human",
"content": "请推演各关键行为体的预期行为、驱动因素和风险。"
},
{
"id": "demo-behavior-ai-1",
"type": "ai",
"content": "已完成家长、学员、招生顾问和合作渠道的行为推演,并给出安全表达、咨询响应、体验到场和渠道质量四类风险的校正建议。完整 Markdown 报告已交付。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T09:58:00+08:00"
},
"revision": 1,
"upstreamRevision": {
"stage-1-seat-1-demo-intelligence-agent": 1,
"stage-2-seat-1-demo-md-analysis-agent": 1,
"stage-3-seat-1-demo-xwt-analysis-agent": 1
},
"rejectionCount": 0,
"lastRejection": null,
"invalidatedBy": null,
"createdAt": "2026-07-23T09:43:00+08:00",
"updatedAt": "2026-07-23T09:58:00+08:00"
},
{
"id": "local-demo-node-xs",
"sessionId": "local-demo-position-roundtable-full-v1",
"nodeKey": "stage-5-seat-1-demo-xs-analysis-agent",
"stageIndex": 4,
"seatIndex": 0,
"agentId": "demo-xs-analysis-agent",
"positionId": "planning",
"threadId": null,
"status": "done",
"latestAnswer": "组织协同分析已完成,明确项目负责人、内容、投放、活动执行、招生顾问和数据复盘的职责与协作节奏。",
"artifactManifest": [
{
"name": "组织协同分析报告-暑期游泳夏令营宣传.md",
"path": "mnt/user-data/outputs/组织协同分析报告-暑期游泳夏令营宣传.md",
"mime_type": "text/markdown",
"size_bytes": 2634,
"modified_at": "2026-07-23T10:12:00+08:00",
"content": "# 组织协同分析报告:暑期游泳夏令营宣传\n\n## 组织结构\n\n- **项目负责人**:统一目标、预算、节奏和跨岗位决策。\n- **内容运营**:生产安全、师资、课程与学员成长内容。\n- **投放运营**:负责渠道组合、素材测试和预算调整。\n- **活动执行**:组织公开课、体验日和现场服务。\n- **招生顾问**:承接线索、完成咨询和报名转化。\n- **数据负责人**:维护漏斗看板并输出每周复盘。\n\n## 协同机制\n\n1. 每周一确认目标、预算和重点素材。\n2. 每周三复盘线索质量与家长高频顾虑。\n3. 每周五复盘渠道ROI、体验到场率和报名转化率。\n4. 重大安全或舆情问题由项目负责人在2小时内组织处置。\n\n## 交付与验收\n\n所有岗位使用统一任务清单,明确负责人、截止时间、交付物和验收标准;未通过验收的交付进入修订队列,不直接推进到下游。"
}
],
"conversationSnapshot": {
"messages": [
{
"id": "demo-xs-human-1",
"type": "human",
"content": "请形成组织协同、资源配置和执行保障建议。"
},
{
"id": "demo-xs-ai-1",
"type": "ai",
"content": "组织协同方案已完成,明确项目负责人、内容、投放、活动、招生和数据岗位的责任边界,并建立周一计划、周三线索复盘、周五转化复盘的协同节奏。完整 Markdown 报告已交付。"
}
],
"inputDraft": "",
"savedAt": "2026-07-23T10:12:00+08:00"
},
"revision": 1,
"upstreamRevision": {
"stage-1-seat-1-demo-intelligence-agent": 1,
"stage-2-seat-1-demo-md-analysis-agent": 1,
"stage-3-seat-1-demo-xwt-analysis-agent": 1,
"stage-4-seat-1-demo-behavior-analysis-agent": 1
},
"rejectionCount": 0,
"lastRejection": null,
"invalidatedBy": null,
"createdAt": "2026-07-23T09:59:00+08:00",
"updatedAt": "2026-07-23T10:12:00+08:00"
}
]
}
}
@@ -0,0 +1,71 @@
import type { AgentThreadState } from "@/core/threads/types";
import type { PositionConversationSnapshot } from "@/position-roundtable/api/sessions";
type ThreadMessage = AgentThreadState["messages"][number];
export function readConversationMessages(
snapshot: PositionConversationSnapshot | null | undefined,
): AgentThreadState["messages"] {
return Array.isArray(snapshot?.messages)
? (snapshot.messages as unknown as AgentThreadState["messages"])
: [];
}
export function readConversationDraft(
snapshot: PositionConversationSnapshot | null | undefined,
): string {
return typeof snapshot?.inputDraft === "string" ? snapshot.inputDraft : "";
}
/**
* Prefer live messages while retaining SQL-only history when the LangGraph
* checkpoint volume was unavailable. Stable message ids remove duplicates
* after the checkpoint becomes readable again.
*/
export function mergeConversationMessages(
persisted: AgentThreadState["messages"],
live: AgentThreadState["messages"],
): AgentThreadState["messages"] {
if (persisted.length === 0) return live;
if (live.length === 0) return persisted;
const merged: ThreadMessage[] = [...persisted];
const indexById = new Map<string, number>();
merged.forEach((message, index) => {
if (message.id) indexById.set(String(message.id), index);
});
for (const message of live) {
const id = message.id ? String(message.id) : "";
const existingIndex = id ? indexById.get(id) : undefined;
if (existingIndex === undefined) {
if (id) indexById.set(id, merged.length);
merged.push(message);
} else {
merged[existingIndex] = message;
}
}
return merged;
}
export function buildConversationSnapshot(
messages: AgentThreadState["messages"],
inputDraft: string,
): PositionConversationSnapshot {
// SDK messages are JSON data at the network boundary. Round-trip them here
// to strip any accidental prototype values before sending the SQL snapshot.
let serializable: Array<Record<string, unknown>> = [];
try {
serializable = JSON.parse(JSON.stringify(messages)) as Array<Record<string, unknown>>;
} catch {
serializable = messages.map((message) => ({
id: message.id ? String(message.id) : undefined,
type: message.type,
content: message.content,
}));
}
return {
messages: serializable,
inputDraft,
savedAt: new Date().toISOString(),
};
}
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,7 @@
* **不要**在这里再 fetch / 建 thread / 跑 SSE —— 这些逻辑都在 `useStep1Intent`,
* 本组件保持纯展示 + 透传事件。
*/
import { useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
CheckCircle,
FileText,
@@ -128,6 +128,10 @@ export interface Step1PanelProps {
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
*/
inputArea?: (props: Step1InputAreaProps) => React.ReactNode;
/** Optional database-backed composer state used by the position workspace. */
inputDraftKey?: string;
initialInputText?: string;
onInputTextChange?: (value: string) => void;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarifications: (
@@ -157,14 +161,20 @@ export function Step1Panel(props: Step1PanelProps) {
footerAction,
hideEnterRoundtable = false,
inputArea,
inputDraftKey,
initialInputText = "",
onInputTextChange,
onManualSave,
onTriggerToast,
submitIntentClarifications,
} = props;
const step1ScrollRef = useRef<HTMLDivElement>(null);
const step1ContentRef = useRef<HTMLDivElement>(null);
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
useAutoScroll(step1ScrollRef, [interactiveMessages]);
useAutoScroll(step1ScrollRef, [interactiveMessages], {
contentRef: step1ContentRef,
});
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
@@ -173,6 +183,16 @@ export function Step1Panel(props: Step1PanelProps) {
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
useEffect(() => {
setInputText(initialInputText);
}, [inputDraftKey, initialInputText]);
const handleInputTextChange = useCallback(
(value: string) => {
setInputText(value);
onInputTextChange?.(value);
},
[onInputTextChange],
);
// 待发送的本地附件(点发送时连同文本一起上传)。
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -202,7 +222,7 @@ export function Step1Panel(props: Step1PanelProps) {
const handleSend = () => {
if (!inputText.trim() && attachedFiles.length === 0) return;
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
setInputText("");
handleInputTextChange("");
setAttachedFiles([]);
};
@@ -234,21 +254,23 @@ export function Step1Panel(props: Step1PanelProps) {
<div
ref={step1ScrollRef}
// overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white"
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
>
{interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarifications={submitIntentClarifications}
/>
))}
<div ref={step1ContentRef} className="space-y-4 p-6">
{interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarifications={submitIntentClarifications}
/>
))}
</div>
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
</div>
@@ -257,7 +279,7 @@ export function Step1Panel(props: Step1PanelProps) {
{inputArea ? (
inputArea({
value: inputText,
onValueChange: setInputText,
onValueChange: handleInputTextChange,
attachedFiles,
onAddFiles: addAttachedFiles,
onRemoveFile: removeAttachedFile,
@@ -16,6 +16,12 @@ export function useAutoScroll<T extends HTMLElement>(
options: {
/** How close to the bottom (px) still counts as "at bottom". */
threshold?: number;
/**
* Optional inner content element. Streamed Markdown, clarification cards
* and expandable tool steps can change height without replacing the outer
* message array; observing this node keeps bottom-following reliable.
*/
contentRef?: RefObject<HTMLElement | null>;
} = {},
): {
/** True when the user is at the bottom — drives the button's visibility. */
@@ -24,6 +30,7 @@ export function useAutoScroll<T extends HTMLElement>(
scrollToBottom: () => void;
} {
const threshold = options.threshold ?? 32;
const contentRef = options.contentRef;
// Ref mirrors state so the deps-effect can read the latest value without
// re-subscribing when atBottom flips. State is for the button's render.
const atBottomRef = useRef(true);
@@ -35,7 +42,10 @@ export function useAutoScroll<T extends HTMLElement>(
// - 任何其它 scroll 事件视为用户主动滚动,按位置判定 atBottom。
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
const programmaticScrollRef = useRef(false);
// Keep the expected destination rather than a boolean. Setting scrollTop to
// its current value emits no event; a boolean would remain set and swallow
// the user's next real upward scroll.
const programmaticScrollTargetRef = useRef<number | null>(null);
// 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。
// 用来在 effect 里区分两种 distance 暴涨的情况:
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
@@ -52,9 +62,13 @@ export function useAutoScroll<T extends HTMLElement>(
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
if (programmaticScrollRef.current) {
const programmaticTarget = programmaticScrollTargetRef.current;
if (
programmaticTarget !== null &&
Math.abs(currentScrollTop - programmaticTarget) <= 1
) {
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
programmaticScrollRef.current = false;
programmaticScrollTargetRef.current = null;
prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
// 程序滚动后通常落在底部,把 atBottom 维持 true。
@@ -64,6 +78,9 @@ export function useAutoScroll<T extends HTMLElement>(
}
return;
}
if (programmaticTarget !== null) {
programmaticScrollTargetRef.current = null;
}
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
@@ -115,19 +132,56 @@ export function useAutoScroll<T extends HTMLElement>(
return;
}
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
programmaticScrollRef.current = true;
programmaticScrollTargetRef.current = Math.max(
0,
el.scrollHeight - el.clientHeight,
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
// Some message UI grows after the React state update that triggered the
// dependency effect (Markdown layout, clarification cards, collapsed step
// details). Follow the actual content height as well, but only while the
// user has not intentionally scrolled upward.
useEffect(() => {
const el = ref.current;
const content = contentRef?.current;
if (!el || !content || typeof ResizeObserver === "undefined") return;
let frameId: number | null = null;
const observer = new ResizeObserver(() => {
if (!atBottomRef.current) return;
if (frameId !== null) window.cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
frameId = null;
if (!atBottomRef.current) return;
programmaticScrollTargetRef.current = Math.max(
0,
el.scrollHeight - el.clientHeight,
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
});
});
observer.observe(content);
return () => {
observer.disconnect();
if (frameId !== null) window.cancelAnimationFrame(frameId);
};
}, [contentRef, ref]);
// 按钮点击是用户主动行为,保留 smooth 给视觉反馈;期间用户若立刻又拖动,
// onScroll 的方向检测仍会把 atBottom 翻回 false,不会卡住。
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
// 标记程序滚动:smooth 动画过程中浏览器派发的 scroll 事件不应被误判为用户主动滚动。
programmaticScrollRef.current = true;
programmaticScrollTargetRef.current = Math.max(
0,
el.scrollHeight - el.clientHeight,
);
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
atBottomRef.current = true;
setAtBottom(true);
@@ -647,17 +647,27 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
[intentClarificationSelections, sendIntentMessage, triggerToast],
);
const hydrateFromDraft = useCallback((snapshot: Step1Snapshot) => {
setIntentThreadId(snapshot.intentThreadId);
setIntentReady(snapshot.intentReady);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(snapshot.interactiveMessages);
setTimelineEntries(snapshot.timelineEntries);
step1InitRef.current = true;
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
step1InitGenRef.current += 1;
}, []);
const hydrateFromDraft = useCallback(
(snapshot: Step1Snapshot) => {
// 切换历史会话时,旧 thread 可能仍在流式回复。先终止它并清掉仅属于
// 上一个会话的“发送中/待澄清”瞬时状态,避免新会话输入框被锁住,
// 或继续显示旧会话的澄清选择。
stopIntentStream();
setIsIntentSending(false);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setIntentThreadId(snapshot.intentThreadId);
setIntentReady(snapshot.intentReady);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(snapshot.interactiveMessages);
setTimelineEntries(snapshot.timelineEntries);
step1InitRef.current = true;
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
step1InitGenRef.current += 1;
},
[stopIntentStream],
);
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
setInteractiveMessages((prev) => [...prev, msg]);
+21 -14
View File
@@ -29,12 +29,14 @@ import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
const iconButtonClass = (isDark: boolean) =>
const iconButtonClass = (isDark: boolean, isIceBlue: boolean) =>
cn(
"size-8 shrink-0 rounded-sm",
isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
: isIceBlue
? "text-[#18324A] hover:bg-white/55 hover:text-[#18324A] active:bg-white/75"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
@@ -48,6 +50,7 @@ type HeaderProps = {
const Header = ({ collapsed }: HeaderProps) => {
const { theme } = useTheme();
const isDark = theme === "dark";
const isIceBlue = theme === "iceblue";
const darkLogoPosition =
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
const isDarkLogoOnLeft = darkLogoPosition === "left";
@@ -149,11 +152,11 @@ const Header = ({ collapsed }: HeaderProps) => {
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = (
<>
<CompactShortcutButtons tone={isDark ? "banner-dark" : "banner-light"} />
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
<CompactShortcutButtons tone={isDark ? "banner-dark" : isIceBlue ? "content" : "banner-light"} />
<ThemeSwitcher tone={isDark ? "banner-dark" : isIceBlue ? "content" : "banner-light"} />
<LayoutModeSwitcher
tone={isDark ? "banner-dark" : "banner-light"}
tone={isDark ? "banner-dark" : isIceBlue ? "content" : "banner-light"}
fallback="full"
/>
@@ -162,7 +165,7 @@ const Header = ({ collapsed }: HeaderProps) => {
variant="ghost"
size="icon-sm"
onClick={() => setNotificationOpen(true)}
className={cn(iconButtonClass(isDark), "relative")}
className={cn(iconButtonClass(isDark, isIceBlue), "relative")}
title="通知"
>
<Bell className="size-4" />
@@ -183,7 +186,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<div
className={cn(
"mx-1 h-8 w-px",
isDark ? "bg-blue-800/30" : "bg-white/30",
isDark ? "bg-blue-800/30" : isIceBlue ? "bg-[#3D6E96]/25" : "bg-white/30",
)}
aria-hidden
/>
@@ -195,7 +198,9 @@ const Header = ({ collapsed }: HeaderProps) => {
"h-8 gap-2 rounded-sm px-2",
isDark
? "text-slate-200 hover:bg-slate-800/50"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
: isIceBlue
? "text-[#18324A] hover:bg-white/55 active:bg-white/75"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
)}
>
<div
@@ -214,11 +219,11 @@ const Header = ({ collapsed }: HeaderProps) => {
/>
</div>
<div className="hidden text-left sm:block">
<p className="text-sm leading-tight font-medium text-white">
<p className={cn("text-sm leading-tight font-medium", isIceBlue ? "text-[#18324A]" : "text-white")}>
{displayName}
</p>
{positionName && (
<p className="text-[11px] leading-tight text-white/70">
<p className={cn("text-[11px] leading-tight", isIceBlue ? "text-[#315B7B]" : "text-white/70")}>
{positionName}
</p>
)}
@@ -226,7 +231,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<ChevronDown
className={cn(
"size-4 shrink-0",
isDark ? "text-slate-500" : "text-white/80",
isDark ? "text-slate-500" : isIceBlue ? "text-[#315B7B]" : "text-white/80",
)}
/>
</Button>
@@ -318,7 +323,7 @@ const Header = ({ collapsed }: HeaderProps) => {
onError={handleBrandLogoError}
/>
</div>
<span className="whitespace-nowrap text-lg font-semibold text-white">
<span className={cn("whitespace-nowrap text-lg font-semibold", isIceBlue ? "text-[#18324A]" : "text-white")}>
{desensitizeText("CM业务中心")}
</span>
</div>
@@ -339,7 +344,9 @@ const Header = ({ collapsed }: HeaderProps) => {
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white/20",
: isIceBlue
? "bg-white/55"
: "bg-white/20",
)}
>
<img
@@ -351,7 +358,7 @@ const Header = ({ collapsed }: HeaderProps) => {
/>
</div>
{!collapsed && (
<span className="text-sm font-semibold text-white">
<span className={cn("text-sm font-semibold", isIceBlue ? "text-[#18324A]" : "text-white")}>
{desensitizeText("CM业务中心")}
</span>
)}
@@ -24,7 +24,7 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const cardBorder = isDark ? 'border-blue-800/30' : isLightBlue ? 'border-[#D4E9FF]' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
@@ -28,7 +28,7 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const cardBorder = isDark ? 'border-blue-800/30' : isLightBlue ? 'border-[#D4E9FF]' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
@@ -28,7 +28,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const [pageNum, setPageNum] = useState(1);
const handlePageChange = async (value: number) => {
@@ -34,7 +34,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
const { theme } = useTheme();
const navigate = useNavigate();
const isDark = theme === 'dark';
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const [searchQuery, setSearchQuery] = useState('');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
@@ -34,7 +34,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
const { theme } = useTheme();
const navigate = useNavigate();
const isDark = theme === 'dark';
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const [searchQuery, setSearchQuery] = useState('');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
@@ -55,7 +55,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
const { setStartConfig } = useChatContext();
const navigate = useNavigate();
const isDark = theme === 'dark';
const isLightBlue = theme === 'lightblue';
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
const typeConfig = getTaskTypeConfig(task.taskType);
const TypeIcon = typeConfig.icon;
@@ -8,6 +8,7 @@
/* ── CSS variables (light - red) ─────────────────────────────────────── */
.strategy-data {
--color-main: #cf1322;
--color-main-foreground: #ffffff;
--bg-color: #fef6f6;
--card-border: #fa9196;
--text-color: #222222;
@@ -30,6 +31,19 @@
--input-border: 211 100% 92%;
}
/* Ice blue uses the global #9BCEF1 primary with a dark selected-state label. */
[data-theme="iceblue"] .strategy-data {
--color-main: #9BCEF1;
--color-main-foreground: #18324A;
--bg-color: #E1F1FB;
--card-border: #9BCEF1;
--text-color: #18324A;
--card-hover: 201 68% 96%;
--border-color: 204 50% 90%;
--input-border: 204 50% 90%;
}
/* ── CSS variables (dark) ────────────────────────────────────────────── */
.dark .strategy-data {
--color-main: var(--color-primary);
@@ -310,7 +324,7 @@ html[theme-mode='dark'] .sentiment-date-range-popup .t-button {
.strategy-data .task-item-active:hover .item-active {
background: var(--color-main);
color: #ffffff;
color: var(--color-main-foreground);
}
.dark .strategy-data .dark-theme:hover {
@@ -336,7 +350,7 @@ html[theme-mode='dark'] .sentiment-date-range-popup .t-button {
.strategy-data .task-active .item-active {
background: var(--color-main);
color: #ffffff;
color: var(--color-main-foreground);
}
.strategy-data .task-tab-color {
@@ -42,13 +42,14 @@ export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
* 我方主题色键。当前启用 red(浅色) / dark-blue(深色);blue 为后续将新增的主题。
* 主题参数的映射以这些键为左侧 key(如 `dark-blue=darkBlue`)。
*/
export type AppThemeColor = "red" | "green" | "blue" | "dark-blue";
export type AppThemeColor = "red" | "green" | "blue" | "iceblue" | "dark-blue";
/** 我方全部主题色(「应用管理」主题参数逐项配置目标值时遍历;dot 为配色小圆点)。 */
export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string }[] = [
{ key: "green", label: "绿色", dot: "#07C05F" },
{ key: "red", label: "红色", dot: "#F5222D" },
{ key: "blue", label: "蓝色", dot: "#1677FF" },
{ key: "iceblue", label: "冰川蓝", dot: "#9BCEF1" },
{ key: "dark-blue", label: "深蓝色", dot: "#002B5C" },
];
@@ -60,6 +61,7 @@ const THEME_KEY_ALIASES: Record<AppThemeColor, string[]> = {
red: ["red", "light"],
green: ["green", "lightgreen"],
blue: ["blue"],
iceblue: ["iceblue", "ice-blue"],
"dark-blue": ["dark-blue", "darkblue", "dark"],
};
@@ -73,6 +75,7 @@ export function getCurrentTheme(): AppThemeColor {
if (classList.contains("dark")) return "dark-blue";
if (classList.contains("lightgreen")) return "green";
if (classList.contains("lightblue")) return "blue";
if (classList.contains("iceblue")) return "iceblue";
}
return "red";
}
+120
View File
@@ -465,6 +465,66 @@
--legacy-workspace-brand-rgb: 7 192 95;
}
/* Ice blue: #9BCEF1 is the signature hue. The darker #3D6E96 action token
keeps white labels, focus states, and compact controls accessible. */
.iceblue {
--workspace-brand: #9bcef1;
--workspace-brand-foreground: #18324a;
--brand-200: #e1f1fb;
--brand-900: #3d6e96;
--brand-950: #2d587a;
--background: #f7fbfe;
--foreground: #18324a;
--card: #ffffff;
--card-foreground: #18324a;
--popover: #ffffff;
--popover-foreground: #18324a;
/* The requested primary token: all standard primary controls inherit #9BCEF1. */
--primary: #9BCEF1;
--primary-foreground: #18324a;
--secondary: #eff7fc;
--secondary-foreground: #2d587a;
--muted: #edf5fa;
--muted-foreground: #58718a;
--accent: #e1f1fb;
--accent-foreground: #2d587a;
--destructive: #d14343;
--border: #cfe2f0;
--input: #c5dcea;
--ring: rgb(155 206 241 / 0.72);
--chart-1: #3d6e96;
--chart-2: #2e95a8;
--chart-3: #6c7fa2;
--chart-4: #d58a49;
--chart-5: #3a9d7c;
--sidebar: #f9fcfe;
--sidebar-foreground: #18324a;
--sidebar-primary: #9BCEF1;
--sidebar-primary-foreground: #18324a;
--sidebar-accent: #e1f1fb;
--sidebar-accent-foreground: #2d587a;
--sidebar-border: #dceaf3;
--sidebar-ring: rgb(155 206 241 / 0.72);
--legacy-accent-rgb: 225 241 251;
--legacy-background-rgb: 247 251 254;
--legacy-border-rgb: 207 226 240;
--legacy-brand-200-rgb: 225 241 251;
--legacy-card-rgb: 255 255 255;
--legacy-destructive-rgb: 209 67 67;
--legacy-foreground-rgb: 24 50 74;
--legacy-input-rgb: 197 220 234;
--legacy-muted-rgb: 237 245 250;
--legacy-muted-foreground-rgb: 88 113 138;
--legacy-primary-rgb: 155 206 241;
--legacy-primary-foreground-rgb: 24 50 74;
--legacy-ring-rgb: 155 206 241;
--legacy-secondary-rgb: 239 247 252;
--legacy-sidebar-accent-rgb: 225 241 251;
--legacy-sidebar-border-rgb: 220 234 243;
--legacy-sidebar-foreground-rgb: 24 50 74;
--legacy-workspace-brand-rgb: 155 206 241;
}
@layer base {
* {
@apply border-border outline-ring/50;
@@ -1374,6 +1434,43 @@ html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #07c05f 35%, transparent);
}
html.iceblue .strategy-app-header {
background-color: #9bcef1;
background-image: linear-gradient(105deg, #4d7da4 0%, #9bcef1 52%, #d8effd 100%);
}
html.iceblue *::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgb(177 220 246 / 0.86), rgb(61 110 150 / 0.9))
padding-box;
}
html.iceblue .workspace-panel {
border: 1px solid #cfe2f0;
box-shadow:
0 32px 72px -46px rgb(49 101 143 / 0.3),
0 12px 28px -22px rgb(115 171 210 / 0.2);
}
html.iceblue .workspace-user-bubble {
border: 1px solid rgb(255 255 255 / 0.2);
background: linear-gradient(135deg, #78afd4 0%, #568bb2 58%, #3d6e96 100%);
box-shadow:
0 22px 40px -28px rgb(61 110 150 / 0.52),
0 10px 20px -16px rgb(45 88 122 / 0.36);
}
html.iceblue .workspace-assistant-card {
border: 1px solid #d7e8f3;
}
html.iceblue .workspace-assistant-card::before {
background: linear-gradient(180deg, rgb(155 206 241 / 0.82), rgb(155 206 241 / 0.12));
}
html.iceblue .tiptap-writing-editor .ProseMirror ::selection {
background: rgb(155 206 241 / 0.5);
}
/* ── 来源上传弹窗:A系统基因库的分段单选条(custom-radio) ─────────────── */
/* 外层容器,模拟 nz-radio-group */
.custom-radio-group {
@@ -1475,6 +1572,17 @@ html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
border-color: rgb(7 192 95 / 0.5);
}
.iceblue .agent-avatar {
background-color: #e1f1fb;
color: #2d587a;
box-shadow: inset 0 0 0 1px rgb(61 110 150 / 0.16);
}
.iceblue .agent-sidebar-card--active {
background-color: #e1f1fb;
border-color: rgb(61 110 150 / 0.5);
}
@supports not (color: oklch(1 0 0)) {
.bg-primary\/5 { background-color: rgba(190, 35, 35, 0.05); }
.bg-primary\/8 { background-color: rgba(190, 35, 35, 0.08); }
@@ -1537,4 +1645,16 @@ html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
.lightgreen .bg-primary\/60 { background-color: rgba(7, 192, 95, 0.60); }
.lightgreen .bg-primary\/70 { background-color: rgba(7, 192, 95, 0.70); }
.lightgreen .bg-primary\/90 { background-color: rgba(7, 192, 95, 0.90); }
.iceblue .bg-primary\/5 { background-color: rgba(155, 206, 241, 0.05); }
.iceblue .bg-primary\/8 { background-color: rgba(155, 206, 241, 0.08); }
.iceblue .bg-primary\/10 { background-color: rgba(155, 206, 241, 0.10); }
.iceblue .bg-primary\/14 { background-color: rgba(155, 206, 241, 0.14); }
.iceblue .bg-primary\/15 { background-color: rgba(155, 206, 241, 0.15); }
.iceblue .bg-primary\/20 { background-color: rgba(155, 206, 241, 0.20); }
.iceblue .bg-primary\/25 { background-color: rgba(155, 206, 241, 0.25); }
.iceblue .bg-primary\/30 { background-color: rgba(155, 206, 241, 0.30); }
.iceblue .bg-primary\/60 { background-color: rgba(155, 206, 241, 0.60); }
.iceblue .bg-primary\/70 { background-color: rgba(155, 206, 241, 0.70); }
.iceblue .bg-primary\/90 { background-color: rgba(155, 206, 241, 0.90); }
}
+33
View File
@@ -195,6 +195,39 @@
--td-bg-color-secondarycontainer-active: #eaf4ff;
}
/* Ice blue: #9BCEF1 is the primary token; darker blues support readable text
and interaction states. */
:root[data-theme="iceblue"] {
--td-brand-color-1: #f7fbfe;
--td-brand-color-2: #eff8fc;
--td-brand-color-3: #e1f1fb;
--td-brand-color-4: #cbe7f7;
--td-brand-color-5: #9BCEF1;
--td-brand-color-6: #9BCEF1;
--td-brand-color-7: #84b9de;
--td-brand-color-8: #5b91b9;
--td-brand-color-9: #3d6e96;
--td-brand-color-10: #1e405c;
--td-brand-color: #9BCEF1;
--td-brand-color-hover: #b7dcf5;
--td-brand-color-focus: #e1f1fb;
--td-brand-color-active: #84b9de;
--td-brand-color-disabled: #d4eaf8;
--td-brand-color-light: #e1f1fb;
--td-brand-color-light-hover: #cbe7f7;
--td-text-color-primary: #18324a;
--td-text-color-secondary: #58718a;
--td-text-color-brand: #2d587a;
--td-text-color-link: #2d587a;
--td-text-color-anti: #18324a;
--td-bg-color-container-hover: #f7fbfe;
--td-bg-color-container-active: #e1f1fb;
--td-bg-color-secondarycontainer: #eff7fc;
--td-bg-color-secondarycontainer-hover: #e1f1fb;
--td-bg-color-secondarycontainer-active: #cbe7f7;
}
/* WeKnora green: selected by ThemeContext through data-theme="lightgreen". */
:root[data-theme="lightgreen"] {
--td-brand-color-1: #e9f8ec;